< Summary

Line coverage
0%
Covered lines: 0
Uncovered lines: 2708
Coverable lines: 2708
Total lines: 4962
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 1586
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: .ctor(...)100%210%
File 1: get_ViewModel()100%210%
File 1: Index()0%1482380%
File 1: BuildStocksViewModelAsync()0%155001240%
File 1: GetGoogleSyncAutomationStatus()0%110100%
File 1: SaveGoogleSyncAutomation()0%7280%
File 1: GetGoogleSyncAutomationHistory(...)0%6162780%
File 1: TriggerGoogleSyncAutomationNow()100%210%
File 1: IsGoogleAutoJob(...)0%7280%
File 1: BuildHoursCron(...)0%620%
File 1: ParseHoursFromCron(...)0%156120%
File 1: RetryHangfireLockWaitTimeoutAsync()0%620%
File 1: IsLockWaitTimeout(...)100%210%
File 1: get_IsEnabled()100%210%
File 1: get_IntervalHours()100%210%
File 1: get_JobId()100%210%
File 1: get_Status()100%210%
File 1: get_StartedAt()100%210%
File 1: get_FinishedAt()100%210%
File 1: get_DurationMs()100%210%
File 1: get_Error()100%210%
File 1: SaveFilter()0%4260%
File 1: DeleteFilter()0%620%
File 1: GetFilter()0%620%
File 1: get_Name()100%210%
File 1: get_FilterData()100%210%
File 1: get_ClientName()100%210%
File 1: get_BitrixWarehouseNames()100%210%
File 1: get_Articles()100%210%
File 1: GetOneCStocksByArticles()0%2040%
File 1: FromArray()0%620%
File 1: ParseOneCStockArticles(...)0%110100%
File 1: GetOzonProductArchiveByOfferIds()0%272160%
File 1: FromArray()0%620%
File 1: ParseOzonArchiveOfferIds(...)0%110100%
File 1: LoadOzonProductArchiveStatusesAsync()0%2040%
File 1: <LoadOzonProductArchiveStatusesAsync()0%420200%
File 1: StartClearJob()0%506220%
File 1: GetClearJobs()100%210%
File 1: GetClearJobDetails()0%620%
File 1: GetWarehouseMappingsForClient()0%620%
File 1: StartSyncReport(...)0%342180%
File 1: StartSyncReportApply(...)0%272160%
File 1: StartSyncReportPricePush(...)0%4260%
File 1: GetCleanupPreview()0%110100%
File 1: ZeroCleanupWarehouse()0%4260%
File 1: StartCleanup(...)0%110100%
File 1: GetOzonOrphanPreview()0%620%
File 1: StartOzonOrphanCleanup(...)0%4260%
File 1: GetOzonReportFiles()0%620%
File 1: DownloadOzonReportFile()0%156120%
File 1: GetArchivedPreview()0%620%
File 1: ListSyncReports()0%2040%
File 1: GetSyncPresets()100%210%
File 1: CreateSyncPreset()0%272160%
File 1: UpdateSyncPreset()0%110100%
File 1: DeleteSyncPreset()100%210%
File 1: GetSyncReportOneCStores()0%620%
File 1: GetSyncReportMappingCombos()0%620%
File 1: GetSyncReport()0%2040%
File 1: DownloadSyncReportLog()0%2040%
File 1: GetSyncReportProductNames()0%812280%
File 1: DebugOzonArticleLookup()0%1806420%
File 1: DebugFbsStockPush()0%4422660%
File 1: GetOrderReserveMap()0%2040%
File 1: ExportOrderReserveMapExcel()0%2040%
File 1: BuildReserveMapExcel(...)0%812280%
File 1: ExportSyncReportExcel(...)100%210%
File 1: StartSyncReportExcelExportTask(...)0%620%
File 1: GetSyncReportExcelExportTaskStatus(...)0%4260%
File 1: DownloadSyncReportExcelExportTaskResult(...)0%7280%
File 1: SendSyncReportEmail(...)0%620%
File 1: BuildSyncReportQuery(...)100%210%
File 1: GetApplyArticleLog()0%4260%
File 1: GetApplyLogJobs()0%620%
File 1: GetApplyLogAnalysis()0%4260%
File 1: get_OfferIds()100%210%
File 1: UnarchiveReportProducts()0%1806420%
File 1: ArchiveReportProducts()0%1806420%
File 1: BuildSyncReportExcelAsync(...)100%210%
File 1: StartPushJob()0%702260%
File 1: GetPushJobs()100%210%
File 1: GetPushJobDetails()0%620%
File 1: StartArchiveJob()0%702260%
File 1: GetArchiveJobs()100%210%
File 1: GetArchiveJobDetails()0%620%
File 1: StartUnarchiveJob()0%702260%
File 1: GetUnarchiveJobs()100%210%
File 1: GetUnarchiveJobDetails()0%620%
File 1: ParseAutoScheduleFilter()0%2040%
File 1: PreviewAutoSchedule()0%2040%
File 1: GetAutoSchedules()100%210%
File 1: CreateAutoSchedule()0%620%
File 1: UpdateAutoSchedule()0%4260%
File 1: DeleteAutoSchedule()0%620%
File 1: TriggerAutoSchedule()0%110100%
File 1: GetAutoScheduleRuns()0%620%
File 1: ValidateAutoScheduleRequest(...)0%110100%
File 1: GetAutoScheduleDefaultFilter()0%2040%
File 1: BuildAutoScheduleFilterJsonAsync()100%210%
File 1: MapAutoScheduleEntityAsync()0%2040%
File 1: MapAutoScheduleDto(...)0%2040%
File 1: DescribeStoredScheduleFilter(...)100%210%
File 1: ApplyFiltersAsync()0%1640400%
File 1: GetFullExceptionMessage(...)0%2040%
File 1: LoadOzonWarehousesOptimizedAsync()0%4260%
File 1: GetBitrixStoreNamesForMapping(...)0%156120%
File 1: GetAllowedBitrixStoreNames(...)100%210%
File 1: PreloadAllWarehousesAsync()100%210%
File 1: <PreloadAllWarehousesAsync()0%620%
File 1: ProcessClientWarehousesAsync()0%272160%
File 1: ProcessItemWarehousesAsync()0%7280%
File 1: LoadOzonPriceIndexesAsync()0%272160%
File 1: <LoadOzonPriceIndexesAsync()0%272160%
File 1: ParsePriceIndexes(...)0%1640400%
File 1: GetOzonPrices()0%4422660%
File 1: GetSyncReportPriceInfo()0%620%
File 1: GetSyncReportAggregates()0%620%
File 1: GetSyncReportPricedArticles()0%620%
File 1: GetSyncReportPromos()0%620%
File 1: GetSyncReportPriceSettings()0%2040%
File 1: GetArticleC1Price()0%620%
File 1: GetPriceOptimization()0%1640400%
File 1: GetSyncReportOzonPrices()0%2040%
File 1: GetLiveOzonPricesForModal()0%812280%
File 1: EnrichOzonPriceItemInline(...)0%506220%
File 1: GetOrCreateWarehouseCache(...)100%210%
File 1: GetOrCreateWarehouseCache()0%2040%
File 1: UpdateProductStocks()0%1190340%
File 1: UpdateProductPrice()0%506220%
File 1: SetProductPrice()0%1056320%
File 1: EnsureWarehouseMappings()0%156120%
File 1: ToggleActiveStatus()0%620%
File 1: EnsureWarehouseMappingsInternalAsync()100%210%
File 1: SetClients()0%4260%
File 1: GetPriceIndexValue()0%156120%
File 1: WaitForIndexChange()0%210140%
File 1: SendSummaryAndReturn()0%2040%
File 1: PreloadWarehouseCachesAsync()0%620%
File 1: <PreloadWarehouseCachesAsync()0%2040%
File 1: HasPointedProductSearch(...)0%620%
File 1: HasNarrowingSearch(...)0%600240%
File 1: IsWideOzonBrowsing(...)0%620%
File 1: ShouldSyncLinkedStocksToOzonOnLoad(...)100%210%
File 1: IsOzonLoadFailure(...)0%156120%
File 1: DescribeOzonLoadFailure(...)0%210140%
File 1: BuildArticlesCacheKeyPart(...)0%2040%
File 1: IsBareBitrixStockPageLoad()0%620%
File 1: AppendPerformanceNotice(...)0%4260%
File 1: GetExceptionArticles()100%210%
File 1: UpsertExceptionArticle()0%620%
File 1: UpdateExceptionArticle()0%620%
File 1: DeleteExceptionArticle()100%210%
File 1: GetClientPrices()100%210%
File 1: UpsertClientPrice()0%620%
File 1: UpdateClientPrice()0%620%
File 1: DeleteClientPrice()100%210%
File 1: GetStockRules()100%210%
File 1: UpsertStockRule()0%620%
File 1: UpdateStockRule()0%620%
File 1: DeleteStockRule()100%210%
File 1: FormatRecommendationError(...)0%110100%
File 1: HasOzonCredentials(...)0%620%
File 1: TryGetOzonCredentials(...)0%4260%
File 1: CreateOzonApiDataManager(...)100%210%
File 1: TryConfigureOzonApiClient(...)0%620%
File 2: GetStocksGrid()0%156120%
File 3: GetSyncRunsGrid()0%2040%
File 3: GetSyncReportGridRows()0%4260%
File 3: GetSyncReportGridFacets()0%7280%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/BitrixStockController.cs

#LineLine coverage
 1using ClosedXML.Excel;
 2using System.Collections.Concurrent;
 3using System.Diagnostics;
 4using System.Security.Cryptography;
 5using System.Security.Claims;
 6using System.Text;
 7using System.Text.Json;
 8using Hangfire;
 9using Hangfire.Common;
 10using Hangfire.States;
 11using Hangfire.Storage;
 12using Microsoft.AspNetCore.Authorization;
 13using Microsoft.AspNetCore.Mvc;
 14using Microsoft.Extensions.Caching.Memory;
 15using Microsoft.Extensions.Http;
 16using MySqlConnector;
 17using SlqStudio.Application.Services.EmailService;
 18using Newtonsoft.Json.Linq;
 19using OzonDomains;
 20using OzonDomains.Models;
 21using OzonDomains.Models.BitrixModels;
 22using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix;
 23using OzonOrdersWeb.Areas.Studio2.Views;
 24using OzonRepositories.Data;
 25using OzonRepositories.Data.Bitrix;
 26using Services.ApiServices;
 27using Services.ApiServices._1CApi;
 28using Services.ApiServices._1CApi.Models;
 29using Services.ApiServices.OzonApi;
 30using Services.ApiServices.OzonApi.Filters;
 31using Services.ApiServices.YandexApi;
 32using Services.BackgroundJobsServices;
 33using Services.ChannelTaskServices;
 34using Services.BitrixStockServices;
 35using Services.DataServices;
 36using Services.ImportProductPricesServices;
 37using Services.PriceСlculationServices;
 38using Services.SignalRServices;
 39using Services.StockSyncServices;
 40using Services.HangfireService;
 41using OzonOrdersWeb.Extensions;
 42using OzonOrdersWeb.Authorization;
 43
 44[Area("Studio2")]
 45[Authorize(Roles = "User,Admin")]
 46[Route("BitrixStock")]
 47[Route("Studio2/BitrixStock")]
 48public partial class BitrixStockController : Controller
 49{
 50    private const int MaxArticlesForOzonStocksLoad = 500;
 51    private const int MaxOfferIdsForOzonArchiveBatch = 100;
 52    private const int OzonProductInfoListChunkSize = 500;
 53    private const string GoogleSyncRecurringJobId = "warehouse-stock-sync-google-auto";
 54    private const string GoogleSyncTriggeredBy = "BitrixStockGoogleAutoSchedule";
 55
 056    private readonly List<string> _ozonLoadWarnings = new();
 57    private BitrixOzonStockSyncSummary? _ozonLinkageSyncReport;
 58    private readonly BitrixStockRepository _repository;
 59    private readonly EtProducerRepository _etProducerRepository;
 60    private readonly OzonClientServices _ozonClientServices;
 61    private readonly IHttpClientFactory _httpClientFactory;
 62    private readonly IEmailService? _emailService;
 63    private OzonApiDataManager _ozonApiDataManager = null!;
 064    private List<OzonClient> _ozonClients = new List<OzonClient>();
 65    private CurrencyRateFetcher _currencyRateFetcher;
 66    private SupplierDataServices _supplierDataServices;
 67    private readonly IPriceHistoryDataService _priceHistoryService;
 68    private readonly WarehouseMappingDataServices _warehouseMappingDataServices;
 69    private readonly IMemoryCache _memoryCache;
 70    private readonly ImportProductPricesManager _importProductPricesManager;
 71    private readonly BitrixStockSavedFilterDataServices _savedFilterDataServices;
 72    private readonly OneCDataManager _oneCDataManager;
 73    private readonly IBitrixStockClearJobService _bitrixStockClearJobService;
 74    private readonly IBitrixStockClearJobRepository _bitrixStockClearJobRepository;
 75    private readonly IBitrixStockArchiveJobService _bitrixStockArchiveJobService;
 76    private readonly IBitrixStockArchiveJobRepository _bitrixStockArchiveJobRepository;
 77    private readonly IBitrixStockUnarchiveJobService _bitrixStockUnarchiveJobService;
 78    private readonly IBitrixStockUnarchiveJobRepository _bitrixStockUnarchiveJobRepository;
 79    private readonly IBitrixStockPushJobService _bitrixStockPushJobService;
 80    private readonly IBitrixStockPushJobRepository _bitrixStockPushJobRepository;
 81    private readonly IBitrixStockAutoScheduleService _bitrixStockAutoScheduleService;
 82    private readonly IBitrixStockInventoryService _bitrixStockInventoryService;
 83    private readonly IBitrixOzonLinkedStockApiSyncService _bitrixOzonLinkedStockApiSyncService;
 84    private readonly IWarehouseStockBitrixViewService _warehouseStockBitrixViewService;
 85    private readonly IBitrixStockSyncReportService _bitrixStockSyncReportService;
 86    private readonly IBitrixStockSyncReportApplyService _bitrixStockSyncReportApplyService;
 87    private readonly IBitrixStockSyncReportCleanupService _bitrixStockSyncReportCleanupService;
 88    private readonly IBitrixStockSyncReportPricePushService _bitrixStockSyncReportPricePushService;
 89    private readonly IBitrixStockSyncPresetService _bitrixStockSyncPresetService;
 90    private readonly IBitrixStockOzonOrphanCleanupService _bitrixStockOzonOrphanCleanupService;
 91    private readonly BitrixStockSyncApplyArticleLogService _applyArticleLogService;
 92    private readonly BitrixStockActiveStageStore _activeStageStore;
 93    private readonly IBitrixStockExceptionService _exceptionService;
 94    private readonly IChannelTaskService _channelTasks;
 95    private readonly IRecurringJobManager _recurringJobManager;
 96
 097    private record BitrixCacheEntry(RemainingStockViewModel ViewModel, DateTime? MaxTimestamp);
 98    private sealed class OzonPerfCounters
 99    {
 100        public int ItemsChecked;
 101        public int ItemStocksFound;
 102        public int WarehouseInfoCalls;
 103        public long WarehouseInfoMs;
 104    }
 105
 0106    public BitrixStockController(BitrixStockRepository repository,
 0107        EtProducerRepository etProducerRepository,
 0108        OzonClientServices ozonClientServices,
 0109        IHttpClientFactory httpClientFactory,
 0110        IEmailService? emailService,
 0111        CurrencyRateFetcher currencyRateFetcher,
 0112        SupplierDataServices supplierDataServices,
 0113        IPriceHistoryDataService priceHistoryService,
 0114        WarehouseMappingDataServices warehouseMappingDataServices,
 0115        IMemoryCache memoryCache,
 0116        ImportProductPricesManager importProductPricesManager,
 0117        BitrixStockSavedFilterDataServices savedFilterDataServices,
 0118        OneCDataManager oneCDataManager,
 0119        IBitrixStockClearJobService bitrixStockClearJobService,
 0120        IBitrixStockClearJobRepository bitrixStockClearJobRepository,
 0121        IBitrixStockArchiveJobService bitrixStockArchiveJobService,
 0122        IBitrixStockArchiveJobRepository bitrixStockArchiveJobRepository,
 0123        IBitrixStockUnarchiveJobService bitrixStockUnarchiveJobService,
 0124        IBitrixStockUnarchiveJobRepository bitrixStockUnarchiveJobRepository,
 0125        IBitrixStockPushJobService bitrixStockPushJobService,
 0126        IBitrixStockPushJobRepository bitrixStockPushJobRepository,
 0127        IBitrixStockAutoScheduleService bitrixStockAutoScheduleService,
 0128        IBitrixStockInventoryService bitrixStockInventoryService,
 0129        IBitrixOzonLinkedStockApiSyncService bitrixOzonLinkedStockApiSyncService,
 0130        IWarehouseStockBitrixViewService warehouseStockBitrixViewService,
 0131        IBitrixStockSyncReportService bitrixStockSyncReportService,
 0132        IBitrixStockSyncReportApplyService bitrixStockSyncReportApplyService,
 0133        IBitrixStockSyncReportCleanupService bitrixStockSyncReportCleanupService,
 0134        IBitrixStockSyncReportPricePushService bitrixStockSyncReportPricePushService,
 0135        IBitrixStockSyncPresetService bitrixStockSyncPresetService,
 0136        IBitrixStockOzonOrphanCleanupService bitrixStockOzonOrphanCleanupService,
 0137        BitrixStockSyncApplyArticleLogService applyArticleLogService,
 0138        BitrixStockActiveStageStore activeStageStore,
 0139        IBitrixStockExceptionService exceptionService,
 0140        IChannelTaskService channelTasks,
 0141        IRecurringJobManager recurringJobManager)
 142    {
 0143        _repository = repository;
 0144        _etProducerRepository = etProducerRepository;
 0145        _ozonClientServices = ozonClientServices;
 0146        _httpClientFactory = httpClientFactory;
 0147        _emailService = emailService;
 0148        _currencyRateFetcher = currencyRateFetcher;
 0149        _supplierDataServices = supplierDataServices;
 0150        _priceHistoryService = priceHistoryService;
 0151        _warehouseMappingDataServices = warehouseMappingDataServices;
 0152        _memoryCache = memoryCache;
 0153        _importProductPricesManager = importProductPricesManager;
 0154        _savedFilterDataServices = savedFilterDataServices;
 0155        _oneCDataManager = oneCDataManager;
 0156        _bitrixStockClearJobService = bitrixStockClearJobService;
 0157        _bitrixStockClearJobRepository = bitrixStockClearJobRepository;
 0158        _bitrixStockArchiveJobService = bitrixStockArchiveJobService;
 0159        _bitrixStockArchiveJobRepository = bitrixStockArchiveJobRepository;
 0160        _bitrixStockUnarchiveJobService = bitrixStockUnarchiveJobService;
 0161        _bitrixStockUnarchiveJobRepository = bitrixStockUnarchiveJobRepository;
 0162        _bitrixStockPushJobService = bitrixStockPushJobService;
 0163        _bitrixStockPushJobRepository = bitrixStockPushJobRepository;
 0164        _bitrixStockAutoScheduleService = bitrixStockAutoScheduleService;
 0165        _bitrixStockInventoryService = bitrixStockInventoryService;
 0166        _bitrixOzonLinkedStockApiSyncService = bitrixOzonLinkedStockApiSyncService;
 0167        _warehouseStockBitrixViewService = warehouseStockBitrixViewService;
 0168        _bitrixStockSyncReportService = bitrixStockSyncReportService;
 0169        _bitrixStockSyncReportApplyService = bitrixStockSyncReportApplyService;
 0170        _bitrixStockSyncReportCleanupService = bitrixStockSyncReportCleanupService;
 0171        _bitrixStockSyncReportPricePushService = bitrixStockSyncReportPricePushService;
 0172        _bitrixStockSyncPresetService = bitrixStockSyncPresetService;
 0173        _bitrixStockOzonOrphanCleanupService = bitrixStockOzonOrphanCleanupService;
 0174        _applyArticleLogService = applyArticleLogService;
 0175        _activeStageStore = activeStageStore;
 0176        _exceptionService = exceptionService;
 0177        _channelTasks = channelTasks;
 0178        _recurringJobManager = recurringJobManager;
 0179    }
 180
 181    public async Task<IActionResult> Index(
 182        [FromQuery] RemainingStockFilter filter,
 183        [FromQuery] string tab = "stocks",
 184        [FromQuery] string? syncSubTab = null)
 185    {
 0186        if (filter.Page <= 0) filter.Page = 1;
 0187        if (filter.PageSize <= 0) filter.PageSize = 100;
 0188        _ozonLinkageSyncReport = null;
 189
 0190        if (string.Equals(tab, "tasks", StringComparison.OrdinalIgnoreCase))
 191        {
 0192            return RedirectToAction(nameof(Index), new { area = "Studio2", tab = "stocks" });
 193        }
 194
 0195        var activeTab = string.Equals(tab, "clearHistory", StringComparison.OrdinalIgnoreCase)
 0196            ? "clearHistory"
 0197            : string.Equals(tab, "pushHistory", StringComparison.OrdinalIgnoreCase)
 0198                ? "pushHistory"
 0199                : string.Equals(tab, "sync", StringComparison.OrdinalIgnoreCase)
 0200                    ? "sync"
 0201                    : string.Equals(tab, "exceptions", StringComparison.OrdinalIgnoreCase)
 0202                        ? "exceptions"
 0203                        : "stocks";
 204
 205        // История запусков (обнуление/накат) и sync/exceptions не читают остатки —
 206        // им хватает лёгкой VM без выборки WarehouseStock.
 0207        if (activeTab is "sync" or "exceptions" or "clearHistory" or "pushHistory")
 208        {
 0209            var isSyncLike = activeTab is "sync" or "exceptions";
 0210            if (isSyncLike)
 211            {
 0212                ViewBag.SyncSubTab = string.Equals(syncSubTab, "google-auto", StringComparison.OrdinalIgnoreCase)
 0213                    ? "google-auto"
 0214                    : "pipeline";
 215            }
 216
 0217            var ozonClients = (await _ozonClientServices.GetOzonClients())
 218                .Where(c => c.ClientType == ClientType.OZON)
 0219                .ToList();
 220
 0221            var auxVm = new RemainingStockViewModel
 0222            {
 0223                Result = new PagedResult<RemainingStockBitrix>
 0224                {
 0225                    Items = new List<RemainingStockBitrix>(),
 0226                    Page = 1,
 0227                    PageSize = filter.PageSize,
 0228                    TotalCount = 0
 0229                },
 0230                Filter = filter,
 0231                Suppliers = new List<Supplier>(),
 0232                AllSuppliers = new List<Supplier>(),
 0233                WarehousesmMappings = await _warehouseMappingDataServices.GetWarehouseMappings(),
 0234                ActiveTab = activeTab,
 0235                OzonClients = ozonClients,
 0236                AutoSchedules = new List<BitrixStockAutoSchedule>(),
 0237                ActiveStores = await _warehouseStockBitrixViewService.GetActiveStoresAsync()
 0238            };
 0239            if (isSyncLike)
 0240                ViewBag.ReserveStatusOptions = await _bitrixStockSyncReportService.GetReserveStatusOptionsAsync();
 0241            return View(auxVm);
 242        }
 243
 0244        var vm = await BuildStocksViewModelAsync(filter, loadItems: false);
 0245        return View(vm);
 0246    }
 247
 248    /// <summary>
 249    /// Вкладка «Остатки»: нормализация фильтра (пресеты, склады по умолчанию, лимиты) и модель страницы.
 250    /// <paramref name="loadItems"/> = false — только каркас страницы (фильтры, справочники), строки грузит грид.
 251    /// </summary>
 252    private async Task<RemainingStockViewModel> BuildStocksViewModelAsync(RemainingStockFilter filter, bool loadItems)
 253    {
 0254        if (filter.SelectedClientId == null &&
 0255            Request.Query.TryGetValue(nameof(filter.SelectedClientId), out var selectedClientRawValues))
 256        {
 0257            var firstValidClientId = selectedClientRawValues
 258                .Select(v => v?.Trim())
 259                .FirstOrDefault(v => !string.IsNullOrWhiteSpace(v) && int.TryParse(v, out _));
 0260            if (!string.IsNullOrWhiteSpace(firstValidClientId) && int.TryParse(firstValidClientId, out var parsedClientI
 261            {
 0262                filter.SelectedClientId = parsedClientId;
 263            }
 264        }
 265
 0266        var hasAnyBusinessFilter =
 0267            filter.ProductId.HasValue ||
 0268            !string.IsNullOrWhiteSpace(filter.Article) ||
 0269            !string.IsNullOrWhiteSpace(filter.Supplier) ||
 0270            !string.IsNullOrWhiteSpace(filter.PreviewText) ||
 0271            !string.IsNullOrWhiteSpace(filter.Active) ||
 0272            !string.IsNullOrWhiteSpace(filter.Available) ||
 0273            filter.QuantityFrom.HasValue ||
 0274            filter.QuantityTo.HasValue ||
 0275            filter.DateFrom.HasValue ||
 0276            filter.DateTo.HasValue ||
 0277            filter.PriceValueFrom.HasValue ||
 0278            filter.PriceValueTo.HasValue ||
 0279            filter.RegistrationPriceFrom.HasValue ||
 0280            filter.RegistrationPriceTo.HasValue ||
 0281            filter.StoreId.HasValue ||
 0282            !string.IsNullOrWhiteSpace(filter.StoreTitle) ||
 0283            (filter.SelectedStoreTitles?.Any() == true) ||
 0284            filter.AmountFrom.HasValue ||
 0285            filter.AmountTo.HasValue ||
 0286            filter.SelectedClientId.HasValue ||
 0287            !string.IsNullOrWhiteSpace(filter.OzonStoreTitle) ||
 0288            filter.OzonAmountFrom.HasValue ||
 0289            filter.OzonAmountTo.HasValue ||
 0290            filter.PriceIndexValueFrom.HasValue ||
 0291            filter.PriceIndexValueTo.HasValue ||
 0292            filter.MinPriceFrom.HasValue ||
 0293            filter.MinPriceTo.HasValue ||
 0294            filter.LoadOzonWarehouses ||
 0295            filter.LoadOzonPriceIndexes ||
 0296            filter.DiffOzonWarehouses ||
 0297            filter.RedOzonPriceIndexes ||
 0298            filter.OnlyActive ||
 0299            filter.OnlyInactive;
 300
 301        // Для WarehouseStock источника держим пагинацию всегда включенной,
 302        // иначе поиск при фильтрах может вытягивать слишком много строк.
 0303        filter.DisablePagination = false;
 0304        if (!Request.Query.ContainsKey(nameof(filter.PageSize)))
 305        {
 0306            filter.PageSize = 100;
 307        }
 0308        else if (filter.PageSize > 500)
 309        {
 0310            filter.PageSize = 500;
 311        }
 312
 0313        var activeStores = await _warehouseStockBitrixViewService.GetActiveStoresAsync();
 314
 0315        var hasLoadOzonWarehousesParam = Request.Query.ContainsKey(nameof(filter.LoadOzonWarehouses));
 0316        var hasDiffOzonWarehousesParam = Request.Query.ContainsKey(nameof(filter.DiffOzonWarehouses));
 317
 318        //Пресет фильтрации
 0319        if (filter.OnlyInactive)
 320        {
 0321            if (!hasLoadOzonWarehousesParam)
 322            {
 0323                filter.LoadOzonWarehouses = true;
 324            }
 325
 0326            filter.Active = "N";
 0327            filter.OzonAmountFrom = 1;
 328        }
 329
 0330        if (filter.OnlyActive)
 331        {
 0332            if (!hasLoadOzonWarehousesParam)
 333            {
 0334                filter.LoadOzonWarehouses = true;
 335            }
 336
 0337            filter.Active = "Y";
 338        }
 339
 340        const string storesFilterAppliedKey = "StoresFilterApplied";
 0341        var hasSelectedStoreTitlesKey = Request.Query.ContainsKey(nameof(filter.SelectedStoreTitles));
 0342        var storesFilterApplied = Request.Query.ContainsKey(storesFilterAppliedKey);
 343
 0344        if (!hasSelectedStoreTitlesKey)
 345        {
 0346            if (storesFilterApplied)
 347            {
 0348                filter.SelectedStoreTitles = new List<string>();
 349            }
 0350            else if (!string.IsNullOrWhiteSpace(filter.StoreTitle))
 351            {
 0352                filter.SelectedStoreTitles = new List<string> { filter.StoreTitle };
 353            }
 0354            else if (IsBareBitrixStockPageLoad())
 355            {
 0356                filter.SelectedStoreTitles = BitrixStockFilterNormalizer.DefaultStoreTitlesFor(activeStores).ToList();
 357            }
 358        }
 359
 0360        if (IsWideOzonBrowsing(filter) && filter.PageSize > MaxArticlesForOzonStocksLoad)
 361        {
 0362            filter.PageSize = MaxArticlesForOzonStocksLoad;
 0363            ViewData["PerformanceNotice"] =
 0364                "Для ускорения загрузки в режиме Ozon без поиска размер страницы ограничен 500 записями.";
 365        }
 366
 0367        await SetClients();
 0368        if (filter.SelectedClientId != null)
 369        {
 0370            _ozonClients = _ozonClients.Where(oz => oz.Id == filter.SelectedClientId.Value).ToList();
 371        }
 372
 373        // Страница рисует только каркас: строки грузит грид (stocks-grid) тем же конвейером.
 0374        var pagedResult = loadItems
 0375            ? await _warehouseStockBitrixViewService.GetRemainingStockAsync(filter)
 0376            : new PagedResult<RemainingStockBitrix> { Items = new List<RemainingStockBitrix>(), Page = filter.Page, Page
 377
 0378        var filteredItems = loadItems
 0379            ? await ApplyFiltersAsync(pagedResult.Items, filter)
 0380            : new List<RemainingStockBitrix>();
 381
 0382        var useSingleResultPage = filter.DisablePagination && HasNarrowingSearch(filter);
 0383        var result = new PagedResult<RemainingStockBitrix>
 0384        {
 0385            Items = filteredItems,
 0386            Page = useSingleResultPage ? 1 : filter.Page,
 0387            PageSize = useSingleResultPage
 0388                ? Math.Max(filteredItems.Count, 1)
 0389                : filter.PageSize,
 0390            TotalCount = useSingleResultPage
 0391                ? filteredItems.Count
 0392                : pagedResult.TotalCount
 0393        };
 394
 0395        if (_ozonLoadWarnings.Count > 0)
 396        {
 0397            var ozonWarning = string.Join(" ", _ozonLoadWarnings);
 0398            ViewData["PerformanceNotice"] = ViewData["PerformanceNotice"] is string existing && !string.IsNullOrWhiteSpa
 0399                ? $"{existing} {ozonWarning}"
 0400                : ozonWarning;
 401        }
 402
 0403        if (_ozonLinkageSyncReport is { Attempted: > 0 })
 404        {
 0405            ViewData["OzonLinkageSyncReport"] = _ozonLinkageSyncReport;
 406        }
 407
 0408        var vm = new RemainingStockViewModel
 0409        {
 0410            Result = result,
 0411            Filter = filter,
 0412            Suppliers = await _supplierDataServices.GetSuppliersWithUrlAsync(),
 0413            AllSuppliers = await _supplierDataServices.GetSuppliers(),
 0414            WarehousesmMappings = await _warehouseMappingDataServices.GetWarehouseMappings(),
 0415            ActiveStores = activeStores,
 0416            RateEUR = await _currencyRateFetcher.GetEURRateAsync(),
 0417            RateUSD = await _currencyRateFetcher.GetUSDRateAsync(),
 0418            RateBYN = await _currencyRateFetcher.GetBYNRateAsync(),
 0419            OzonClients = _ozonClients,
 0420            SelectedClient = _ozonClients.FirstOrDefault(c => c.Id == filter.SelectedClientId),
 0421            SavedFilters = await _savedFilterDataServices.GetSavedFilters(),
 0422            ActiveTab = "stocks"
 0423        };
 0424        ViewBag.ReserveStatusOptions = await _bitrixStockSyncReportService.GetReserveStatusOptionsAsync();
 0425        return vm;
 0426    }
 427
 428
 429    [HttpGet("/Studio2/BitrixStock/sync/google-automation/status")]
 430    public IActionResult GetGoogleSyncAutomationStatus()
 431    {
 0432        using var connection = JobStorage.Current.GetConnection();
 0433        var recurring = connection.GetRecurringJobs().FirstOrDefault(x => x.Id == GoogleSyncRecurringJobId);
 0434        var hours = ParseHoursFromCron(recurring?.Cron);
 435
 0436        return Ok(new
 0437        {
 0438            success = true,
 0439            isEnabled = recurring != null,
 0440            intervalHours = hours ?? 6,
 0441            cronExpression = recurring?.Cron,
 0442            lastExecution = recurring?.LastExecution,
 0443            nextExecution = recurring?.NextExecution,
 0444            lastJobState = recurring?.LastJobState
 0445        });
 0446    }
 447
 448    [HttpPost("/Studio2/BitrixStock/sync/google-automation/save")]
 449    [IgnoreAntiforgeryToken]
 450    public async Task<IActionResult> SaveGoogleSyncAutomation([FromBody] GoogleSyncAutomationRequest? request)
 451    {
 0452        if (request is null)
 0453            return BadRequest(new { success = false, error = "Не передано тело запроса." });
 0454        if (request.IntervalHours < 1 || request.IntervalHours > 24)
 455        {
 0456            return BadRequest(new { success = false, error = "Интервал должен быть от 1 до 24 часов." });
 457        }
 458
 459        try
 460        {
 0461            if (!request.IsEnabled)
 462            {
 0463                _recurringJobManager.RemoveIfExists(GoogleSyncRecurringJobId);
 0464                return Ok(new { success = true });
 465            }
 466
 0467            var cron = BuildHoursCron(request.IntervalHours);
 0468            await RetryHangfireLockWaitTimeoutAsync(() =>
 0469            {
 0470                _recurringJobManager.AddOrUpdate<WarehouseStockSyncJob>(
 0471                    GoogleSyncRecurringJobId,
 0472                    x => x.ExecuteAsync(GoogleSyncTriggeredBy),
 0473                    cron,
 0474                    new RecurringJobOptions
 0475                    {
 0476                        TimeZone = RussianTimeZoneHelper.Zone
 0477                    });
 0478            });
 0479        }
 0480        catch (Exception ex)
 481        {
 0482            return StatusCode(503, new
 0483            {
 0484                success = false,
 0485                error = $"Hangfire временно занят: {ex.Message}"
 0486            });
 487        }
 488
 0489        return Ok(new { success = true });
 0490    }
 491
 492    [HttpGet("/Studio2/BitrixStock/sync/google-automation/history")]
 493    public IActionResult GetGoogleSyncAutomationHistory([FromQuery] int take = 20)
 494    {
 0495        take = Math.Clamp(take, 1, 100);
 0496        var api = JobStorage.Current.GetMonitoringApi();
 0497        var rows = new List<GoogleSyncHistoryRow>();
 498
 0499        var succeeded = api.SucceededJobs(0, 200)
 0500            .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?.
 0501            .Select(x => new GoogleSyncHistoryRow
 0502            {
 0503                JobId = x.Key,
 0504                Status = "Succeeded",
 0505                StartedAt = x.Value?.SucceededAt?.AddMilliseconds(-(x.Value?.TotalDuration ?? 0)),
 0506                FinishedAt = x.Value?.SucceededAt,
 0507                DurationMs = x.Value?.TotalDuration,
 0508                Error = (string?)null
 0509            });
 510
 0511        var failed = api.FailedJobs(0, 200)
 0512            .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?.
 0513            .Select(x => new GoogleSyncHistoryRow
 0514            {
 0515                JobId = x.Key,
 0516                Status = "Failed",
 0517                StartedAt = x.Value?.FailedAt,
 0518                FinishedAt = x.Value?.FailedAt,
 0519                DurationMs = (long?)null,
 0520                Error = x.Value?.ExceptionMessage
 0521            });
 522
 0523        var processing = api.ProcessingJobs(0, 50)
 0524            .Where(x => IsGoogleAutoJob(x.Value?.Job?.Method?.DeclaringType?.Name, x.Value?.Job?.Method?.Name, x.Value?.
 0525            .Select(x => new GoogleSyncHistoryRow
 0526            {
 0527                JobId = x.Key,
 0528                Status = "Processing",
 0529                StartedAt = x.Value?.StartedAt,
 0530                FinishedAt = (DateTime?)null,
 0531                DurationMs = (long?)null,
 0532                Error = (string?)null
 0533            });
 534
 0535        rows.AddRange(succeeded.OrderByDescending(x => x.FinishedAt ?? x.StartedAt).Take(take));
 0536        rows.AddRange(failed.OrderByDescending(x => x.FinishedAt ?? x.StartedAt).Take(take));
 0537        rows.AddRange(processing.OrderByDescending(x => x.StartedAt).Take(take));
 538
 0539        var result = rows
 0540            .OrderByDescending(x => x.FinishedAt ?? x.StartedAt)
 0541            .Take(take)
 0542            .ToList();
 543
 0544        return Ok(new { success = true, items = result });
 545    }
 546
 547    [HttpPost("/Studio2/BitrixStock/sync/google-automation/trigger")]
 548    [IgnoreAntiforgeryToken]
 549    public IActionResult TriggerGoogleSyncAutomationNow()
 550    {
 0551        BackgroundJob.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync(GoogleSyncTriggeredBy));
 0552        return Ok(new { success = true });
 553    }
 554
 555    private static bool IsGoogleAutoJob(string? typeName, string? methodName, IReadOnlyList<object>? args)
 556    {
 0557        if (!string.Equals(typeName, nameof(WarehouseStockSyncJob), StringComparison.Ordinal) ||
 0558            !string.Equals(methodName, nameof(WarehouseStockSyncJob.ExecuteAsync), StringComparison.Ordinal))
 559        {
 0560            return false;
 561        }
 562
 0563        return args?.Any(a => string.Equals(a?.ToString(), GoogleSyncTriggeredBy, StringComparison.Ordinal)) == true;
 564    }
 565
 566    private static string BuildHoursCron(int hours) =>
 0567        hours >= 24 ? "0 0 * * *" : $"0 */{hours} * * *";
 568
 569    private static int? ParseHoursFromCron(string? cron)
 570    {
 0571        if (string.IsNullOrWhiteSpace(cron))
 0572            return null;
 573
 0574        if (string.Equals(cron.Trim(), "0 0 * * *", StringComparison.Ordinal))
 0575            return 24;
 576
 577        const string marker = "0 */";
 0578        if (!cron.StartsWith(marker, StringComparison.Ordinal))
 0579            return null;
 580
 0581        var parts = cron.Split(' ', StringSplitOptions.RemoveEmptyEntries);
 0582        if (parts.Length < 2 || !parts[1].StartsWith("*/", StringComparison.Ordinal))
 0583            return null;
 584
 0585        return int.TryParse(parts[1].Substring(2), out var h) ? h : null;
 586    }
 587
 588    private static async Task RetryHangfireLockWaitTimeoutAsync(Action action)
 589    {
 590        const int maxAttempts = 3;
 0591        for (var attempt = 1; attempt <= maxAttempts; attempt++)
 592        {
 593            try
 594            {
 0595                action();
 0596                return;
 597            }
 0598            catch (Exception ex) when (IsLockWaitTimeout(ex) && attempt < maxAttempts)
 599            {
 0600                await Task.Delay(200 * attempt);
 601            }
 602        }
 0603    }
 604
 605    private static bool IsLockWaitTimeout(Exception ex) =>
 0606        ex.Message.Contains("Lock wait timeout exceeded", StringComparison.OrdinalIgnoreCase);
 607
 608    public sealed class GoogleSyncAutomationRequest
 609    {
 0610        public bool IsEnabled { get; set; }
 0611        public int IntervalHours { get; set; } = 6;
 612    }
 613
 614    private sealed class GoogleSyncHistoryRow
 615    {
 0616        public string JobId { get; set; } = string.Empty;
 0617        public string Status { get; set; } = string.Empty;
 0618        public DateTime? StartedAt { get; set; }
 0619        public DateTime? FinishedAt { get; set; }
 0620        public long? DurationMs { get; set; }
 0621        public string? Error { get; set; }
 622    }
 623
 624    [HttpPost("SaveFilter")]
 625    [IgnoreAntiforgeryToken]
 626    public async Task<IActionResult> SaveFilter([FromBody] SaveFilterRequest request)
 627    {
 0628        if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.FilterData))
 629        {
 0630            return BadRequest(new { success = false, message = "Имя и данные фильтра обязательны" });
 631        }
 632
 633        try
 634        {
 0635            var savedFilter = new BitrixStockSavedFilter
 0636            {
 0637                Name = request.Name,
 0638                FilterData = request.FilterData,
 0639                CreatedBy = User.Identity?.Name
 0640            };
 641
 0642            await _savedFilterDataServices.AddSavedFilter(savedFilter);
 643
 0644            return Ok(new { success = true, message = "Фильтр успешно сохранен" });
 645        }
 0646        catch (Exception ex)
 647        {
 0648            return StatusCode(500, new { success = false, message = $"Ошибка при сохранении фильтра: {ex.Message}" });
 649        }
 0650    }
 651
 652    [HttpPost("DeleteFilter")]
 653    [IgnoreAntiforgeryToken]
 654    public async Task<IActionResult> DeleteFilter(int id)
 655    {
 656        try
 657        {
 0658            var filter = await _savedFilterDataServices.GetSavedFilterAsync(id);
 0659            if (filter == null)
 660            {
 0661                return NotFound(new { success = false, message = "Фильтр не найден" });
 662            }
 663
 0664            await _savedFilterDataServices.DeleteSavedFilter(filter);
 0665            return Ok(new { success = true, message = "Фильтр успешно удален" });
 666        }
 0667        catch (Exception ex)
 668        {
 0669            return StatusCode(500, new { success = false, message = $"Ошибка при удалении фильтра: {ex.Message}" });
 670        }
 0671    }
 672
 673    [HttpGet("GetFilter")]
 674    public async Task<IActionResult> GetFilter(int id)
 675    {
 676        try
 677        {
 0678            var filter = await _savedFilterDataServices.GetSavedFilterAsync(id);
 0679            if (filter == null)
 680            {
 0681                return NotFound(new { success = false, message = "Фильтр не найден" });
 682            }
 683
 0684            return Ok(new { success = true, filterData = filter.FilterData });
 685        }
 0686        catch (Exception ex)
 687        {
 0688            return StatusCode(500, new { success = false, message = $"Ошибка при загрузке фильтра: {ex.Message}" });
 689        }
 0690    }
 691
 692    public class SaveFilterRequest
 693    {
 0694        public string Name { get; set; } = string.Empty;
 0695        public string FilterData { get; set; } = string.Empty;
 696    }
 697
 698    public class EnsureWarehouseMappingsRequest
 699    {
 0700        public string ClientName { get; set; } = string.Empty;
 0701        public List<string> BitrixWarehouseNames { get; set; } = new();
 702    }
 703
 704    public class GetOneCStocksBatchRequest
 705    {
 0706        public List<string> Articles { get; set; } = new();
 707    }
 708
 709    [HttpPost("GetOneCStocksByArticles")]
 710    [IgnoreAntiforgeryToken]
 711    public async Task<IActionResult> GetOneCStocksByArticles([FromBody] JsonElement body)
 712    {
 0713        var articles = ParseOneCStockArticles(body);
 0714        if (articles.Count == 0)
 715        {
 0716            return BadRequest(new { success = false, error = "Не переданы артикулы (ожидается {\"Articles\":[\"...\"]} и
 717        }
 718
 0719        if (articles.Count > 100)
 720        {
 0721            return BadRequest(new { success = false, error = "Максимум 100 артикулов в одном запросе" });
 722        }
 723
 724        try
 725        {
 0726            var response = await _oneCDataManager.GetStockByArticlesAsync(articles);
 0727            return Ok(response);
 728        }
 0729        catch (Exception ex)
 730        {
 0731            return StatusCode(500, new { success = false, error = ex.Message });
 732        }
 0733    }
 734
 735    private static List<string> ParseOneCStockArticles(JsonElement body)
 736    {
 737        static List<string> FromArray(JsonElement array) =>
 0738            array.EnumerateArray()
 0739                .Where(e => e.ValueKind == JsonValueKind.String)
 0740                .Select(e => e.GetString()?.Trim())
 0741                .Where(s => !string.IsNullOrWhiteSpace(s))
 0742                .Select(s => s!)
 0743                .Distinct(StringComparer.OrdinalIgnoreCase)
 0744                .ToList();
 745
 0746        if (body.ValueKind == JsonValueKind.Array)
 747        {
 0748            return FromArray(body);
 749        }
 750
 0751        if (body.ValueKind != JsonValueKind.Object)
 752        {
 0753            return new List<string>();
 754        }
 755
 0756        foreach (var propName in new[] { "Articles", "articles" })
 757        {
 0758            if (body.TryGetProperty(propName, out var articlesProp) &&
 0759                articlesProp.ValueKind == JsonValueKind.Array)
 760            {
 0761                return FromArray(articlesProp);
 762            }
 763        }
 764
 0765        return new List<string>();
 766    }
 767
 768    [HttpPost("GetOzonProductArchiveByOfferIds")]
 769    [IgnoreAntiforgeryToken]
 770    public async Task<IActionResult> GetOzonProductArchiveByOfferIds([FromBody] JsonElement body)
 771    {
 0772        var offerIds = ParseOzonArchiveOfferIds(body);
 0773        if (offerIds.Count == 0)
 774        {
 0775            return BadRequest(new
 0776            {
 0777                success = false,
 0778                error = "Не переданы offer_id (ожидается {\"OfferIds\":[\"артикул=префикс\"]} или массив строк)"
 0779            });
 780        }
 781
 0782        if (offerIds.Count > MaxOfferIdsForOzonArchiveBatch)
 783        {
 0784            return BadRequest(new
 0785            {
 0786                success = false,
 0787                error = $"Максимум {MaxOfferIdsForOzonArchiveBatch} offer_id в одном запросе"
 0788            });
 789        }
 790
 0791        int? clientId = null;
 0792        if (body.ValueKind == JsonValueKind.Object)
 793        {
 0794            foreach (var propName in new[] { "ClientId", "clientId", "SelectedClientId", "selectedClientId" })
 795            {
 0796                if (body.TryGetProperty(propName, out var clientProp) &&
 0797                    clientProp.ValueKind == JsonValueKind.Number &&
 0798                    clientProp.TryGetInt32(out var parsed) &&
 0799                    parsed > 0)
 800                {
 0801                    clientId = parsed;
 0802                    break;
 803                }
 804            }
 805        }
 806
 807        try
 808        {
 0809            await SetClients();
 0810            var items = await LoadOzonProductArchiveStatusesAsync(offerIds, clientId);
 0811            return Ok(new { success = true, items });
 812        }
 0813        catch (Exception ex)
 814        {
 0815            return StatusCode(500, new { success = false, error = ex.Message });
 816        }
 0817    }
 818
 819    private static List<string> ParseOzonArchiveOfferIds(JsonElement body)
 820    {
 821        static List<string> FromArray(JsonElement array) =>
 0822            array.EnumerateArray()
 0823                .Where(e => e.ValueKind == JsonValueKind.String)
 0824                .Select(e => e.GetString()?.Trim())
 0825                .Where(s => !string.IsNullOrWhiteSpace(s))
 0826                .Select(s => s!)
 0827                .Distinct(StringComparer.OrdinalIgnoreCase)
 0828                .ToList();
 829
 0830        if (body.ValueKind == JsonValueKind.Array)
 831        {
 0832            return FromArray(body);
 833        }
 834
 0835        if (body.ValueKind != JsonValueKind.Object)
 836        {
 0837            return new List<string>();
 838        }
 839
 0840        foreach (var propName in new[] { "OfferIds", "offerIds", "Articles", "articles" })
 841        {
 0842            if (body.TryGetProperty(propName, out var offerProp) &&
 0843                offerProp.ValueKind == JsonValueKind.Array)
 844            {
 0845                return FromArray(offerProp);
 846            }
 847        }
 848
 0849        return new List<string>();
 850    }
 851
 852    private async Task<List<OzonProductArchiveStatus>> LoadOzonProductArchiveStatusesAsync(
 853        IReadOnlyList<string> offerIds,
 854        int? selectedClientId)
 855    {
 0856        var clients = _ozonClients;
 0857        if (selectedClientId.HasValue)
 858        {
 0859            clients = clients
 0860                .Where(c => c.Id == selectedClientId.Value)
 0861                .ToList();
 862        }
 863
 0864        if (clients.Count == 0)
 865        {
 0866            return new List<OzonProductArchiveStatus>();
 867        }
 868
 0869        var results = new ConcurrentBag<OzonProductArchiveStatus>();
 0870        var tasks = clients.Select(async client =>
 0871        {
 0872            if (!TryGetOzonCredentials(client, out var clientId, out var apiKey))
 0873                return;
 0874
 0875            var mgr = CreateOzonApiDataManager(clientId, apiKey);
 0876
 0877            foreach (var chunk in offerIds.Chunk(OzonProductInfoListChunkSize))
 0878            {
 0879                try
 0880                {
 0881                    var response = await mgr.GetProductInfoListAsync(chunk.ToArray());
 0882                    if (response?["items"] is not JArray items)
 0883                    {
 0884                        continue;
 0885                    }
 0886
 0887                    foreach (var item in items)
 0888                    {
 0889                        var offerId = item["offer_id"]?.ToString();
 0890                        if (string.IsNullOrWhiteSpace(offerId))
 0891                        {
 0892                            continue;
 0893                        }
 0894
 0895                        var ozonProductId = item["id"]?.Value<long>() ?? 0;
 0896                        results.Add(new OzonProductArchiveStatus
 0897                        {
 0898                            ClientName = client.Name,
 0899                            OfferId = offerId,
 0900                            OzonProductId = ozonProductId,
 0901                            IsArchived = item["is_archived"]?.Value<bool>() ?? false,
 0902                            IsAutoArchived = item["is_autoarchived"]?.Value<bool>() ?? false
 0903                        });
 0904                    }
 0905                }
 0906                catch
 0907                {
 0908                    // пропускаем сбой клиента/чанка — ячейка покажет «—»
 0909                }
 0910            }
 0911        });
 912
 0913        await Task.WhenAll(tasks);
 0914        return results.ToList();
 0915    }
 916
 917    [HttpPost("clear-jobs/start")]
 918    [IgnoreAntiforgeryToken]
 919    public async Task<IActionResult> StartClearJob([FromBody] BitrixStockClearStartRequest request)
 920    {
 0921        if (request == null || string.IsNullOrWhiteSpace(request.JobId))
 922        {
 0923            return BadRequest(new { success = false, error = "jobId обязателен" });
 924        }
 925
 0926        if (request.Items == null || request.Items.Count == 0)
 927        {
 0928            return BadRequest(new { success = false, error = "Не переданы позиции для обнуления" });
 929        }
 930
 0931        if (request.Items.Count > 500)
 932        {
 0933            return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" });
 934        }
 935
 0936        foreach (var item in request.Items)
 937        {
 0938            if (string.IsNullOrWhiteSpace(item.OzonClient) ||
 0939                string.IsNullOrWhiteSpace(item.OfferId) ||
 0940                item.ProductId <= 0 ||
 0941                item.WarehouseId <= 0)
 942            {
 0943                return BadRequest(new { success = false, error = "Некорректные данные позиции (клиент, offer, product, с
 944            }
 945        }
 946
 0947        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 0948        var userName = User.Identity?.Name;
 949
 0950        var execution = await _bitrixStockClearJobService.CreateExecutionAsync(
 0951            request.JobId.Trim(),
 0952            request.Items,
 0953            userId,
 0954            userName);
 955
 0956        var hangfireJob = Job.FromExpression<IBitrixStockClearJobService>(service =>
 0957            service.ProcessClearAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None));
 0958        var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQueue
 959
 0960        await _bitrixStockClearJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId);
 961
 0962        return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id });
 0963    }
 964
 965    [HttpGet("clear-jobs")]
 966    public async Task<IActionResult> GetClearJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
 967    {
 0968        var (items, total) = await _bitrixStockClearJobService.GetExecutionsPageAsync(page, pageSize);
 0969        return Ok(new
 0970        {
 0971            success = true,
 0972            page,
 0973            pageSize,
 0974            total,
 975            items = items.Select(e => new
 976            {
 977                e.Id,
 978                e.JobId,
 979                e.Status,
 980                e.TriggerType,
 981                e.StartedAt,
 982                e.CompletedAt,
 983                e.DurationMs,
 984                e.TotalItems,
 985                e.SuccessCount,
 986                e.FailedCount,
 987                e.TriggeredByUserName,
 988                e.ErrorMessage
 989            })
 0990        });
 0991    }
 992
 993    [HttpGet("clear-jobs/{id:long}")]
 994    public async Task<IActionResult> GetClearJobDetails(long id)
 995    {
 0996        var execution = await _bitrixStockClearJobService.GetExecutionDetailsAsync(id);
 0997        if (execution == null)
 998        {
 0999            return NotFound(new { success = false, error = "Задание не найдено" });
 1000        }
 1001
 01002        return Ok(new
 01003        {
 01004            success = true,
 01005            execution = new
 01006            {
 01007                execution.Id,
 01008                execution.JobId,
 01009                execution.HangfireJobId,
 01010                execution.Status,
 01011                execution.StartedAt,
 01012                execution.CompletedAt,
 01013                execution.DurationMs,
 01014                execution.TotalItems,
 01015                execution.SuccessCount,
 01016                execution.FailedCount,
 01017                execution.TriggeredByUserName,
 01018                execution.ErrorMessage,
 1019                items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new
 1020                {
 1021                    i.Id,
 1022                    i.Article,
 1023                    i.OfferId,
 1024                    i.ProductId,
 1025                    i.WarehouseId,
 1026                    i.WarehouseName,
 1027                    i.OzonClient,
 1028                    i.PreviousStock,
 1029                    i.TargetStock,
 1030                    i.Status,
 1031                    i.ErrorMessage,
 1032                    i.ProcessedAt
 1033                })
 01034            }
 01035        });
 01036    }
 1037
 1038    [HttpGet("sync-reports/warehouse-mappings/{clientId}")]
 1039    public async Task<IActionResult> GetWarehouseMappingsForClient(int clientId)
 1040    {
 01041        if (clientId <= 0)
 1042        {
 01043            return BadRequest(new { success = false, error = "Некорректный ID клиента" });
 1044        }
 1045
 1046        try
 1047        {
 01048            var allMappings = await _warehouseMappingDataServices.GetByClientIdAsync(clientId);
 01049            var result = allMappings
 1050                .Select(m => new
 1051                {
 1052                    id = m.Id,
 1053                    ozonWarehouseId = m.OzonWarehouseId,
 1054                    bitrixWarehouse = m.BitrixWarehouseName,
 1055                    ozonWarehouse = m.OzonWarehouseName,
 1056                    description = $"{m.BitrixWarehouseName} → {m.OzonWarehouseName}"
 1057                })
 01058                .ToList();
 1059
 01060            return Ok(new { success = true, mappings = result });
 1061        }
 01062        catch (Exception ex)
 1063        {
 01064            return StatusCode(500, new { success = false, error = ex.Message });
 1065        }
 01066    }
 1067
 1068    [HttpPost("sync-reports/start")]
 1069    [IgnoreAntiforgeryToken]
 1070    public IActionResult StartSyncReport([FromBody] BitrixStockSyncReportStartRequest request)
 1071    {
 01072        if (request == null)
 1073        {
 01074            return BadRequest(new { success = false, error = "Пустой запрос" });
 1075        }
 1076
 01077        if (!request.GetOrderedClientIds().Any())
 1078        {
 01079            return BadRequest(new { success = false, error = "Выберите хотя бы одного клиента Ozon" });
 1080        }
 1081
 01082        var stores = request.SelectedStoreTitles?
 01083            .Where(s => !string.IsNullOrWhiteSpace(s))
 01084            .Select(s => s.Trim())
 01085            .Distinct(StringComparer.OrdinalIgnoreCase)
 01086            .ToList();
 01087        if (stores == null || stores.Count == 0)
 1088        {
 01089            return BadRequest(new { success = false, error = "Выберите хотя бы один склад 1С" });
 1090        }
 1091
 01092        request.SelectedStoreTitles = stores;
 1093
 01094        var jobId = string.IsNullOrWhiteSpace(request.JobId)
 01095            ? Guid.NewGuid().ToString("N")
 01096            : request.JobId.Trim();
 01097        var reportId = jobId;
 1098
 1099        try
 1100        {
 01101            if (_activeStageStore.Get(reportId) != 0)
 01102                return Conflict(new { success = false, error = "Цепочка уже выполняется", activeReportId = reportId });
 1103
 01104            var otherActive = _activeStageStore.GetAnyActiveReportId();
 01105            if (otherActive != null && !string.Equals(otherActive, reportId, StringComparison.Ordinal))
 01106                return Conflict(new { success = false, error = $"Уже выполняется цепочка {otherActive}", activeReportId 
 1107
 1108            // Единая точка входа: оркестратор ведёт всю цепочку этапов 1→5 как одно Hangfire-задание.
 1109            // Без пресета (StageSettings == null) выполнится только этап 1.
 01110            BackgroundJob.Enqueue<IBitrixStockSyncPipelineOrchestrator>(orchestrator =>
 01111                orchestrator.RunPipelineAsync(reportId, request, jobId, CancellationToken.None));
 1112
 01113            return Ok(new { success = true, reportId, jobId });
 1114        }
 01115        catch (Exception ex)
 1116        {
 01117            return StatusCode(500, new { success = false, error = ex.Message });
 1118        }
 01119    }
 1120
 1121    [HttpPost("sync-reports/{reportId}/apply/start")]
 1122    [IgnoreAntiforgeryToken]
 1123    public IActionResult StartSyncReportApply(string reportId, [FromBody] BitrixStockSyncReportApplyStartRequest request
 1124    {
 01125        if (string.IsNullOrWhiteSpace(reportId))
 1126        {
 01127            return BadRequest(new { success = false, error = "reportId обязателен" });
 1128        }
 1129
 01130        if (request == null)
 1131        {
 01132            return BadRequest(new { success = false, error = "Пустой запрос" });
 1133        }
 1134
 01135        request.ReportId = reportId.Trim();
 1136
 01137        var actions = request.Actions?
 01138            .Where(a => !string.IsNullOrWhiteSpace(a))
 01139            .Select(a => a.Trim())
 01140            .Where(a => BitrixStockSyncReportActions.ApplicableToOzon.Contains(a))
 01141            .Distinct(StringComparer.OrdinalIgnoreCase)
 01142            .ToList();
 01143        request.Actions = actions is { Count: > 0 } ? actions : null;
 1144
 01145        if (request.MaxLines is <= 0)
 1146        {
 01147            request.MaxLines = null;
 1148        }
 1149
 01150        var jobId = string.IsNullOrWhiteSpace(request.JobId)
 01151            ? Guid.NewGuid().ToString("N")
 01152            : request.JobId.Trim();
 1153
 1154        try
 1155        {
 01156            BackgroundJob.Enqueue<IBitrixStockSyncReportApplyService>(service =>
 01157                service.ApplyReportAsync(reportId.Trim(), request, jobId, CancellationToken.None));
 1158
 01159            return Ok(new { success = true, reportId = reportId.Trim(), jobId });
 1160        }
 01161        catch (Exception ex)
 1162        {
 01163            return StatusCode(500, new { success = false, error = ex.Message });
 1164        }
 01165    }
 1166
 1167    [HttpPost("sync-reports/{reportId}/price-push/start")]
 1168    [IgnoreAntiforgeryToken]
 1169    public IActionResult StartSyncReportPricePush(string reportId,
 1170        [FromBody] BitrixStockSyncReportPricePushStartRequest request)
 1171    {
 01172        if (string.IsNullOrWhiteSpace(reportId))
 01173            return BadRequest(new { success = false, error = "reportId обязателен" });
 01174        if (request == null)
 01175            return BadRequest(new { success = false, error = "Пустой запрос" });
 1176
 01177        request.ReportId = reportId.Trim();
 01178        var jobId = string.IsNullOrWhiteSpace(request.JobId)
 01179            ? Guid.NewGuid().ToString("N")
 01180            : request.JobId.Trim();
 1181
 1182        try
 1183        {
 01184            BackgroundJob.Enqueue<IBitrixStockSyncReportPricePushService>(svc =>
 01185                svc.PushPricesAsync(reportId.Trim(), request, jobId, CancellationToken.None));
 01186            return Ok(new { success = true, reportId = reportId.Trim(), jobId });
 1187        }
 01188        catch (Exception ex)
 1189        {
 01190            return StatusCode(500, new { success = false, error = ex.Message });
 1191        }
 01192    }
 1193
 1194    [HttpGet("sync-reports/{reportId}/cleanup/preview")]
 1195    [ServiceEndpoint]
 1196    [IgnoreAntiforgeryToken]
 1197    public async Task<IActionResult> GetCleanupPreview(
 1198        string reportId,
 1199        [FromQuery] bool onlyPending = true,
 1200        [FromQuery] int? maxProducts = null,
 1201        [FromQuery] string? search = null,
 1202        [FromQuery] int skipGroups = 0,
 1203        [FromQuery] string[]? articles = null)
 1204    {
 01205        if (string.IsNullOrWhiteSpace(reportId))
 01206            return BadRequest(new { success = false, error = "reportId обязателен" });
 1207
 01208        if (maxProducts is <= 0)
 01209            maxProducts = 15;
 01210        if (skipGroups < 0)
 01211            skipGroups = 0;
 1212
 1213        try
 1214        {
 01215            var articleFilter = articles?
 1216                .Where(a => !string.IsNullOrWhiteSpace(a))
 1217                .Select(a => a.Trim())
 01218                .Distinct(StringComparer.OrdinalIgnoreCase)
 01219                .ToList();
 01220            var preview = await _bitrixStockSyncReportCleanupService.GetCleanupPreviewAsync(
 01221                reportId.Trim(), onlyPending, maxProducts, articleFilter, search: search, skipGroups: skipGroups,
 01222                cancellationToken: HttpContext.RequestAborted);
 01223            return Ok(new { success = true, preview });
 1224        }
 01225        catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
 1226        {
 01227            throw;
 1228        }
 01229        catch (Exception ex)
 1230        {
 01231            return StatusCode(500, new { success = false, error = ex.Message });
 1232        }
 01233    }
 1234
 1235    [HttpPost("sync-reports/{reportId}/cleanup/zero")]
 1236    [ServiceEndpoint]
 1237    [IgnoreAntiforgeryToken]
 1238    public async Task<IActionResult> ZeroCleanupWarehouse(
 1239        string reportId,
 1240        [FromBody] BitrixStockSyncCleanupZeroRequest request)
 1241    {
 01242        if (string.IsNullOrWhiteSpace(reportId))
 01243            return BadRequest(new { success = false, error = "reportId обязателен" });
 01244        if (request == null)
 01245            return BadRequest(new { success = false, error = "Пустой запрос" });
 1246
 1247        try
 1248        {
 01249            var result = await _bitrixStockSyncReportCleanupService.ZeroWarehouseAsync(
 01250                reportId.Trim(), request, HttpContext.RequestAborted);
 01251            if (!result.Success)
 01252                return BadRequest(new { success = false, error = result.Error, result });
 01253            return Ok(new { success = true, result });
 1254        }
 01255        catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
 1256        {
 01257            throw;
 1258        }
 01259        catch (Exception ex)
 1260        {
 01261            return StatusCode(500, new { success = false, error = ex.Message });
 1262        }
 01263    }
 1264
 1265    [HttpPost("sync-reports/{reportId}/cleanup/start")]
 1266    [ServiceEndpoint]
 1267    [IgnoreAntiforgeryToken]
 1268    public IActionResult StartCleanup(string reportId, [FromBody] BitrixStockSyncCleanupStartRequest request)
 1269    {
 01270        if (string.IsNullOrWhiteSpace(reportId))
 01271            return BadRequest(new { success = false, error = "reportId обязателен" });
 1272
 01273        if (request == null)
 01274            return BadRequest(new { success = false, error = "Пустой запрос" });
 1275
 01276        request.ReportId = reportId.Trim();
 1277
 01278        if (request.MaxProducts is <= 0)
 01279            request.MaxProducts = null;
 1280
 01281        var jobId = string.IsNullOrWhiteSpace(request.JobId)
 01282            ? Guid.NewGuid().ToString("N")
 01283            : request.JobId.Trim();
 1284
 1285        try
 1286        {
 01287            BackgroundJob.Enqueue<IBitrixStockSyncReportCleanupService>(service =>
 01288                service.RunCleanupAsync(reportId.Trim(), request, jobId, CancellationToken.None));
 1289
 01290            return Ok(new { success = true, reportId = reportId.Trim(), jobId });
 1291        }
 01292        catch (Exception ex)
 1293        {
 01294            return StatusCode(500, new { success = false, error = ex.Message });
 1295        }
 01296    }
 1297
 1298    // ─── Stage 5: Ozon Orphan Cleanup ────────────────────────────────────────
 1299
 1300    [HttpGet("sync-reports/{reportId}/ozon-orphan-preview")]
 1301    [IgnoreAntiforgeryToken]
 1302    public async Task<IActionResult> GetOzonOrphanPreview(string reportId)
 1303    {
 01304        if (string.IsNullOrWhiteSpace(reportId))
 01305            return BadRequest(new { success = false, error = "reportId обязателен" });
 1306        try
 1307        {
 01308            var preview = await _bitrixStockOzonOrphanCleanupService.GetOrphanPreviewAsync(
 01309                reportId.Trim(), HttpContext.RequestAborted);
 01310            return Ok(new { success = true, preview });
 1311        }
 01312        catch (Exception ex)
 1313        {
 01314            return StatusCode(500, new { success = false, error = ex.Message });
 1315        }
 01316    }
 1317
 1318    [HttpPost("sync-reports/{reportId}/ozon-orphan-cleanup/start")]
 1319    [IgnoreAntiforgeryToken]
 1320    public IActionResult StartOzonOrphanCleanup(string reportId,
 1321        [FromBody] BitrixStockOzonOrphanCleanupRequest request)
 1322    {
 01323        if (string.IsNullOrWhiteSpace(reportId))
 01324            return BadRequest(new { success = false, error = "reportId обязателен" });
 01325        if (request == null)
 01326            return BadRequest(new { success = false, error = "Пустой запрос" });
 1327
 01328        request.ReportId = reportId.Trim();
 01329        var jobId = string.IsNullOrWhiteSpace(request.JobId)
 01330            ? Guid.NewGuid().ToString("N")
 01331            : request.JobId.Trim();
 01332        request.JobId = jobId;
 1333
 1334        try
 1335        {
 01336            BackgroundJob.Enqueue<IBitrixStockOzonOrphanCleanupService>(svc =>
 01337                svc.RunOrphanCleanupAsync(reportId.Trim(), request, jobId, CancellationToken.None));
 01338            return Ok(new { success = true, reportId = reportId.Trim(), jobId });
 1339        }
 01340        catch (Exception ex)
 1341        {
 01342            return StatusCode(500, new { success = false, error = ex.Message });
 1343        }
 01344    }
 1345
 1346    [HttpGet("sync-reports/{reportId}/ozon-files")]
 1347    [IgnoreAntiforgeryToken]
 1348    public async Task<IActionResult> GetOzonReportFiles(string reportId)
 1349    {
 01350        if (string.IsNullOrWhiteSpace(reportId))
 01351            return BadRequest(new { success = false, error = "reportId обязателен" });
 1352        try
 1353        {
 01354            var files = await _bitrixStockOzonOrphanCleanupService.GetReportFilesAsync(
 01355                reportId.Trim(), HttpContext.RequestAborted);
 01356            return Ok(new { success = true, files });
 1357        }
 01358        catch (Exception ex)
 1359        {
 01360            return StatusCode(500, new { success = false, error = ex.Message });
 1361        }
 01362    }
 1363
 1364    /// <summary>
 1365    /// Скачать xlsx-файл Ozon-отчёта по остаткам.
 1366    /// Сначала ищет локальную копию (сохранённую при этапе 5), при отсутствии — редиректит на Ozon URL.
 1367    /// </summary>
 1368    [HttpGet("sync-reports/{reportId}/ozon-file/{fileId:long}/download")]
 1369    [IgnoreAntiforgeryToken]
 1370    public async Task<IActionResult> DownloadOzonReportFile(string reportId, long fileId)
 1371    {
 01372        if (string.IsNullOrWhiteSpace(reportId))
 01373            return BadRequest(new { error = "reportId обязателен" });
 1374        try
 1375        {
 01376            var files = await _bitrixStockOzonOrphanCleanupService.GetReportFilesAsync(
 01377                reportId.Trim(), HttpContext.RequestAborted);
 01378            var file = files.FirstOrDefault(f => f.Id == fileId);
 01379            if (file == null) return NotFound(new { error = "Файл не найден" });
 1380
 1381            // Ищем локально сохранённый файл
 01382            var localPath = Path.Combine(
 01383                AppContext.BaseDirectory, "OzonStockFiles", reportId.Trim(),
 01384                $"{file.OzonReportCode}.xlsx");
 01385            if (System.IO.File.Exists(localPath))
 1386            {
 01387                var reportCode = file.OzonReportCode ?? "report";
 01388                var fileName = $"ozon_stock_{file.BitrixWarehouseName ?? "warehouse"}_{reportCode[..Math.Min(12, reportC
 01389                    .Replace(' ', '_');
 01390                return PhysicalFile(localPath,
 01391                    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
 01392                    fileName);
 1393            }
 1394
 1395            // Fallback: редиректим на Ozon URL (истекает через 30 мин после генерации)
 01396            if (!string.IsNullOrWhiteSpace(file.FileUrl))
 01397                return Redirect(file.FileUrl);
 1398
 01399            return NotFound(new { error = "Локальный файл отсутствует и Ozon URL не сохранён" });
 1400        }
 01401        catch (Exception ex)
 1402        {
 01403            return StatusCode(500, new { error = ex.Message });
 1404        }
 01405    }
 1406
 1407    [HttpGet("sync-reports/{reportId}/archived-preview")]
 1408    [IgnoreAntiforgeryToken]
 1409    public async Task<IActionResult> GetArchivedPreview(string reportId)
 1410    {
 01411        if (string.IsNullOrWhiteSpace(reportId))
 01412            return BadRequest(new { success = false, error = "reportId обязателен" });
 1413
 01414        var preview = await _bitrixStockSyncReportService.GetArchivedPreviewAsync(reportId.Trim());
 01415        return Ok(new
 01416        {
 01417            success = true,
 01418            clients = preview.Clients,
 01419            items = preview.Items,
 01420            totalArchived = preview.TotalArchived,
 01421            totalAuto = preview.TotalAuto,
 01422            totalManual = preview.TotalManual
 01423        });
 01424    }
 1425
 1426    [HttpGet("sync-reports")]
 1427    public async Task<IActionResult> ListSyncReports(
 1428        [FromQuery] int page = 1,
 1429        [FromQuery] int pageSize = 20,
 1430        [FromQuery] int? ozonClientId = null,
 1431        [FromQuery] string? status = null)
 1432    {
 1433        try
 1434        {
 01435            var result = await _bitrixStockSyncReportService.ListReportsAsync(
 01436                new BitrixStockSyncReportRunsQuery
 01437                {
 01438                    Page = page,
 01439                    PageSize = pageSize,
 01440                    OzonClientId = ozonClientId,
 01441                    Status = status
 01442                });
 01443            foreach (var item in result.Items)
 01444                item.ActiveStage = _activeStageStore.Get(item.ReportId ?? string.Empty);
 01445            return Ok(new { success = true, result });
 1446        }
 01447        catch (MySqlException ex) when (ex.Message.Contains("Timeout", StringComparison.OrdinalIgnoreCase))
 1448        {
 01449            return StatusCode(StatusCodes.Status503ServiceUnavailable,
 01450                new { success = false, error = "База данных временно занята. Повторите загрузку отчётов через несколько 
 1451        }
 01452    }
 1453
 1454    // ── Пресеты синхронизации ───────────────────────────────────────────
 1455
 1456    [HttpGet("sync-presets")]
 1457    public async Task<IActionResult> GetSyncPresets()
 1458    {
 01459        var presets = await _bitrixStockSyncPresetService.GetAllAsync();
 01460        return Ok(new { success = true, presets });
 01461    }
 1462
 1463    [HttpPost("sync-presets")]
 1464    [IgnoreAntiforgeryToken]
 1465    public async Task<IActionResult> CreateSyncPreset([FromBody] BitrixStockSyncPresetCreateRequest request)
 1466    {
 01467        if (string.IsNullOrWhiteSpace(request?.Name))
 01468            return BadRequest(new { success = false, error = "Название пресета обязательно" });
 01469        if (request.OzonClientId <= 0)
 01470            return BadRequest(new { success = false, error = "Клиент Ozon обязателен" });
 01471        if (request.SelectedStores == null || request.SelectedStores.Count == 0)
 01472            return BadRequest(new { success = false, error = "Выберите хотя бы один склад" });
 1473
 1474        // Подтягиваем имя клиента и вставляем в запрос
 01475        var client = await _ozonClientServices.GetOzonClientAsync(request.OzonClientId);
 01476        request.OzonClientName = client?.Name ?? $"ID {request.OzonClientId}";
 1477
 01478        var preset = await _bitrixStockSyncPresetService.CreateAsync(request, User.Identity?.Name);
 01479        return Ok(new { success = true, preset });
 01480    }
 1481
 1482    [HttpPut("sync-presets/{id:int}")]
 1483    [IgnoreAntiforgeryToken]
 1484    public async Task<IActionResult> UpdateSyncPreset(int id, [FromBody] BitrixStockSyncPresetUpdateRequest request)
 1485    {
 01486        if (string.IsNullOrWhiteSpace(request?.Name))
 01487            return BadRequest(new { success = false, error = "Название обязательно" });
 1488
 01489        var client = await _ozonClientServices.GetOzonClientAsync(request.OzonClientId);
 01490        request.OzonClientName = client?.Name ?? $"ID {request.OzonClientId}";
 1491
 01492        var updated = await _bitrixStockSyncPresetService.UpdateAsync(id, request);
 01493        if (updated == null) return NotFound(new { success = false, error = "Пресет не найден" });
 1494
 01495        return Ok(new { success = true, preset = updated });
 01496    }
 1497
 1498    [HttpDelete("sync-presets/{id:int}")]
 1499    [IgnoreAntiforgeryToken]
 1500    public async Task<IActionResult> DeleteSyncPreset(int id)
 1501    {
 01502        await _bitrixStockSyncPresetService.DeleteAsync(id);
 01503        return Ok(new { success = true });
 01504    }
 1505
 1506    // ────────────────────────────────────────────────────────────────────
 1507
 1508    [HttpGet("sync-reports/{reportId}/one-c-stores")]
 1509    public async Task<IActionResult> GetSyncReportOneCStores(string reportId)
 1510    {
 01511        if (string.IsNullOrWhiteSpace(reportId))
 1512        {
 01513            return BadRequest(new { success = false, error = "reportId обязателен" });
 1514        }
 1515
 01516        var stores = await _bitrixStockSyncReportService.GetReportOneCStoresAsync(reportId.Trim());
 01517        return Ok(new { success = true, stores });
 01518    }
 1519
 1520    [HttpGet("sync-reports/{reportId}/mapping-combos")]
 1521    public async Task<IActionResult> GetSyncReportMappingCombos(string reportId)
 1522    {
 01523        if (string.IsNullOrWhiteSpace(reportId))
 01524            return BadRequest(new { success = false, error = "reportId обязателен" });
 1525
 01526        var combos = await _bitrixStockSyncReportService.GetReportMappingCombosAsync(reportId.Trim());
 1527        var result = combos.Select(c => new { clientId = c.ClientId, oneCStore = c.OneCStore, ozonWarehouse = c.OzonWare
 01528        return Ok(new { success = true, combos = result });
 01529    }
 1530
 1531    [HttpGet("sync-reports/{reportId}")]
 1532    public async Task<IActionResult> GetSyncReport(
 1533        string reportId,
 1534        [FromQuery] int page = 1,
 1535        [FromQuery] int pageSize = 100,
 1536        [FromQuery] string? article = null,
 1537        [FromQuery] List<string>? oneCStores = null,
 1538        [FromQuery] string? ozonWarehouse = null,
 1539        [FromQuery] string? action = null,
 1540        [FromQuery] List<string>? actions = null,
 1541        [FromQuery] bool onlyRequiresAction = false,
 1542        [FromQuery] bool onlyOzonAppliedSuccess = false,
 1543        [FromQuery] bool onlyOzonAppliedFailed = false,
 1544        [FromQuery] bool onlyArchived = false,
 1545        [FromQuery] bool showZero = false,
 1546        [FromQuery] List<int>? clientIds = null,
 1547        [FromQuery] bool onlyOzonOrphan = false)
 1548    {
 01549        if (string.IsNullOrWhiteSpace(reportId))
 1550        {
 01551            return BadRequest(new { success = false, error = "reportId обязателен" });
 1552        }
 1553
 01554        var report = await _bitrixStockSyncReportService.GetReportAsync(
 01555            reportId.Trim(),
 01556            new BitrixStockSyncReportQuery
 01557            {
 01558                Page = page,
 01559                PageSize = pageSize,
 01560                Article = article,
 01561                OneCStores = oneCStores,
 01562                OzonWarehouse = ozonWarehouse,
 01563                Action = action,
 01564                Actions = actions,
 01565                OnlyRequiresAction = onlyRequiresAction,
 01566                OnlyOzonAppliedSuccess = onlyOzonAppliedSuccess,
 01567                OnlyOzonAppliedFailed = onlyOzonAppliedFailed,
 01568                OnlyArchived = onlyArchived,
 01569                ShowZeroRows = showZero,
 01570                ClientIds = clientIds,
 01571                OnlyOzonOrphan = onlyOzonOrphan
 01572            });
 01573        if (report == null)
 1574        {
 01575            return NotFound(new { success = false, error = "Отчёт ещё формируется или не найден" });
 1576        }
 1577
 01578        return Ok(new { success = true, report });
 01579    }
 1580
 1581    [HttpGet("sync-reports/{reportId}/log")]
 1582    public async Task<IActionResult> DownloadSyncReportLog(string reportId)
 1583    {
 01584        if (string.IsNullOrWhiteSpace(reportId))
 01585            return BadRequest("reportId обязателен");
 1586
 01587        var logText = await _bitrixStockSyncReportService.GetReportLogTextAsync(reportId.Trim());
 01588        if (logText == null)
 01589            return NotFound("Отчёт не найден");
 1590
 01591        var filename = $"sync-log-{reportId[..Math.Min(8, reportId.Length)]}.txt";
 01592        return File(System.Text.Encoding.UTF8.GetBytes(logText), "text/plain; charset=utf-8", filename);
 01593    }
 1594
 1595    /// <summary>
 1596    /// Возвращает словарь offerId → название товара через Ozon API /v3/product/info/list.
 1597    /// Принимает список offer_id с текущей страницы отчёта (до 100 штук).
 1598    /// </summary>
 1599    [HttpGet("sync-reports/{reportId}/product-names")]
 1600    public async Task<IActionResult> GetSyncReportProductNames(
 1601        string reportId,
 1602        [FromQuery] List<string>? offerIds = null)
 1603    {
 01604        if (string.IsNullOrWhiteSpace(reportId))
 01605            return BadRequest(new { success = false, error = "reportId обязателен" });
 1606
 01607        if (offerIds == null || offerIds.Count == 0)
 01608            return Ok(new { success = true, names = new Dictionary<string, string>() });
 1609
 1610        // Получаем OzonClientId из заголовка отчёта
 01611        var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(),
 01612            new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 });
 01613        if (report == null)
 01614            return NotFound(new { success = false, error = "Отчёт не найден" });
 1615
 01616        var client = await _ozonClientServices.GetOzonClientAsync(report.OzonClientId);
 01617        if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey))
 01618            return Ok(new { success = true, names = new Dictionary<string, string>() });
 1619
 01620        var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 1621
 01622        var names = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 1623        try
 1624        {
 01625            foreach (var chunk in offerIds.Distinct(StringComparer.OrdinalIgnoreCase).Chunk(100))
 1626            {
 01627                var response = await apiManager.GetProductInfoListAsync(offerIds: chunk);
 01628                if (response?["items"] is not Newtonsoft.Json.Linq.JArray items)
 1629                    continue;
 01630                foreach (var item in items)
 1631                {
 01632                    var id = item["offer_id"]?.ToString();
 01633                    var name = item["name"]?.ToString();
 01634                    if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
 01635                        names[id] = name;
 1636                }
 1637            }
 01638        }
 01639        catch
 1640        {
 1641            // Если API недоступно — возвращаем пустой словарь, не ломаем UI
 01642        }
 1643
 01644        return Ok(new { success = true, names });
 01645    }
 1646
 1647    /// <summary>
 1648    /// Диагностика: вызывает Ozon API напрямую для указанного артикула и возвращает что нашлось.
 1649    /// GET /sync-reports/{reportId}/debug-ozon?article=0035310SX&amp;articleWithKey=0035310SX%3DSLX
 1650    /// </summary>
 1651    [HttpGet("sync-reports/{reportId}/debug-ozon")]
 1652    [IgnoreAntiforgeryToken]
 1653    [Authorize(Roles = "Admin")]
 1654    public async Task<IActionResult> DebugOzonArticleLookup(
 1655        string reportId,
 1656        [FromQuery] string? article = null,
 1657        [FromQuery] string? articleWithKey = null)
 1658    {
 01659        if (string.IsNullOrWhiteSpace(reportId))
 01660            return BadRequest(new { success = false, error = "reportId обязателен" });
 01661        if (string.IsNullOrWhiteSpace(article) && string.IsNullOrWhiteSpace(articleWithKey))
 01662            return BadRequest(new { success = false, error = "Нужен article или articleWithKey" });
 1663
 01664        var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(),
 01665            new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 });
 01666        if (report == null)
 01667            return NotFound(new { success = false, error = "Отчёт не найден" });
 1668
 01669        var clientIds = report.MultiClientIds.Any() ? report.MultiClientIds : new List<int> { report.OzonClientId };
 01670        var candidates = Services.BitrixStockServices.BitrixOzonStockLinkage.BuildOfferIdCandidates(articleWithKey, arti
 1671
 01672        var resultsJson = new Newtonsoft.Json.Linq.JArray();
 01673        foreach (var clientId in clientIds)
 1674        {
 01675            var client = await _ozonClientServices.GetOzonClientAsync(clientId);
 01676            if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey)) continue;
 1677
 01678            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 01679            var clientResult = new Newtonsoft.Json.Linq.JObject
 01680            {
 01681                ["clientId"] = clientId,
 01682                ["clientName"] = client.Name,
 01683                ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates)
 01684            };
 1685
 1686            // GetProductStocks — остатки по FBO складам
 1687            try
 1688            {
 01689                var stocks = await apiManager.GetProductStocks(candidates.ToArray());
 01690                clientResult["stocksItems"] = stocks?["items"] ?? new Newtonsoft.Json.Linq.JArray();
 01691            }
 01692            catch (Exception ex) { clientResult["stocksError"] = ex.Message; }
 1693
 1694            // GetProductInfoList — offer_id, название, статус архива
 1695            try
 1696            {
 01697                var info = await apiManager.GetProductInfoListAsync(offerIds: candidates.ToArray());
 1698                // Возвращаем только ключевые поля чтобы не захламлять ответ
 01699                var infoItems = new Newtonsoft.Json.Linq.JArray();
 01700                if (info?["items"] is Newtonsoft.Json.Linq.JArray items)
 1701                {
 01702                    foreach (var item in items)
 1703                    {
 01704                        infoItems.Add(new Newtonsoft.Json.Linq.JObject
 01705                        {
 01706                            ["offer_id"] = item["offer_id"],
 01707                            ["id"] = item["id"],
 01708                            ["name"] = item["name"],
 01709                            ["is_archived"] = item["is_archived"],
 01710                            ["is_autoarchived"] = item["is_autoarchived"],
 01711                            ["stocks"] = item["stocks"],
 01712                            ["sources"] = item["sources"]
 01713                        });
 1714                    }
 1715                }
 01716                clientResult["infoItems"] = infoItems;
 01717            }
 01718            catch (Exception ex) { clientResult["infoError"] = ex.Message; }
 1719
 1720            // GetProductWarehousesForBitrix — /v2/product/info/stocks-by-warehouse/fbs
 1721            // Извлекаем SKU из stocksItems и смотрим что возвращает FBS-склад API
 1722            try
 1723            {
 01724                var skus = new List<string>();
 01725                if (clientResult["stocksItems"] is Newtonsoft.Json.Linq.JArray stocksArr)
 1726                {
 01727                    foreach (var si in stocksArr)
 01728                        foreach (var s in si["stocks"] ?? new Newtonsoft.Json.Linq.JArray())
 1729                        {
 01730                            var sku = s["sku"]?.ToString();
 01731                            if (!string.IsNullOrEmpty(sku)) skus.Add(sku);
 1732                        }
 1733                }
 01734                clientResult["fbsWarehouseSkus"] = new Newtonsoft.Json.Linq.JArray(skus);
 01735                if (skus.Count > 0)
 1736                {
 01737                    var warehouseData = await apiManager.GetProductWarehousesForBitrix(skus.ToArray(), System.Array.Empt
 01738                    clientResult["fbsWarehouseResult"] = warehouseData;
 1739                }
 1740                else
 1741                {
 01742                    clientResult["fbsWarehouseResult"] = "Нет SKU в stocks — FBS-склад API не вызван";
 1743                }
 01744            }
 01745            catch (Exception ex) { clientResult["fbsWarehouseError"] = ex.Message; }
 1746
 01747            resultsJson.Add(clientResult);
 01748        }
 1749
 01750        var json = new Newtonsoft.Json.Linq.JObject
 01751        {
 01752            ["success"] = true,
 01753            ["article"] = article,
 01754            ["articleWithKey"] = articleWithKey,
 01755            ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates),
 01756            ["results"] = resultsJson
 01757        };
 01758        return Content(json.ToString(Newtonsoft.Json.Formatting.Indented), "application/json");
 01759    }
 1760
 1761    /// <summary>
 1762    /// Диагностика: пробует накатить FBS-остаток через /v2/products/stocks.
 1763    /// GET /sync-reports/{reportId}/debug-fbs-push?article=0035310SX&warehouse=M2&stock=1&dryRun=true
 1764    /// По умолчанию dryRun=true — только показывает что будет отправлено, без реального вызова.
 1765    /// </summary>
 1766    [HttpGet("sync-reports/{reportId}/debug-fbs-push")]
 1767    [IgnoreAntiforgeryToken]
 1768    [Authorize(Roles = "Admin")]
 1769    public async Task<IActionResult> DebugFbsStockPush(
 1770        string reportId,
 1771        [FromQuery] string? article = null,
 1772        [FromQuery] string? articleWithKey = null,
 1773        [FromQuery] string? warehouse = null,
 1774        [FromQuery] int stock = 1,
 1775        [FromQuery] bool dryRun = true)
 1776    {
 1777        // Debug endpoint — live writes are permanently disabled regardless of query param
 01778        dryRun = true;
 01779        if (string.IsNullOrWhiteSpace(reportId))
 01780            return BadRequest(new { success = false, error = "reportId обязателен" });
 01781        if (string.IsNullOrWhiteSpace(article) && string.IsNullOrWhiteSpace(articleWithKey))
 01782            return BadRequest(new { success = false, error = "Нужен article или articleWithKey" });
 1783
 01784        var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(),
 01785            new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 });
 01786        if (report == null)
 01787            return NotFound(new { success = false, error = "Отчёт не найден" });
 1788
 01789        var clientIds = report.MultiClientIds.Any() ? report.MultiClientIds : new List<int> { report.OzonClientId };
 01790        var candidates = Services.BitrixStockServices.BitrixOzonStockLinkage.BuildOfferIdCandidates(articleWithKey, arti
 1791
 01792        var resultsJson = new Newtonsoft.Json.Linq.JArray();
 01793        foreach (var clientId in clientIds)
 1794        {
 01795            var client = await _ozonClientServices.GetOzonClientAsync(clientId);
 01796            if (client == null || !TryGetOzonCredentials(client, out var ozonClientId, out var ozonApiKey)) continue;
 1797
 01798            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 01799            var clientResult = new Newtonsoft.Json.Linq.JObject
 01800            {
 01801                ["clientId"] = clientId,
 01802                ["clientName"] = client.Name
 01803            };
 1804
 1805            // 1. Список FBS-складов клиента
 01806            var warehouseList = await apiManager.GetWarehouseList();
 01807            var warehousesJson = new Newtonsoft.Json.Linq.JArray(
 1808                warehouseList.Select(w => new Newtonsoft.Json.Linq.JObject
 1809                {
 1810                    ["warehouse_id"] = w.WarehouseId,
 1811                    ["name"] = w.Name,
 1812                    ["status"] = w.Status
 1813                }));
 01814            clientResult["allWarehouses"] = warehousesJson;
 1815
 1816            // 2. Найти склад по имени (M2)
 01817            WarehouseOzon? matchedWarehouse = null;
 01818            if (!string.IsNullOrWhiteSpace(warehouse))
 1819            {
 1820                // Сначала точное совпадение, затем prefix match
 01821                matchedWarehouse =
 01822                    warehouseList.FirstOrDefault(w => string.Equals(w.Name?.Trim(), warehouse.Trim(), StringComparison.O
 01823                    warehouseList.FirstOrDefault(w => w.Name?.Trim().StartsWith(warehouse.Trim(), StringComparison.Ordin
 1824            }
 01825            clientResult["matchedWarehouse"] = matchedWarehouse == null ? null : new Newtonsoft.Json.Linq.JObject
 01826            {
 01827                ["warehouse_id"] = matchedWarehouse.WarehouseId,
 01828                ["name"] = matchedWarehouse.Name
 01829            };
 1830
 1831            // 3. product_id из /v4/product/info/stocks
 01832            long productId = 0;
 01833            string? foundOfferId = null;
 1834            try
 1835            {
 01836                var stocks = await apiManager.GetProductStocks(candidates.ToArray());
 01837                if (stocks?["items"] is Newtonsoft.Json.Linq.JArray items && items.Count > 0)
 1838                {
 01839                    productId = items[0]["product_id"]?.Value<long>() ?? 0;
 01840                    foundOfferId = items[0]["offer_id"]?.ToString();
 1841                }
 01842                clientResult["productId"] = productId;
 01843                clientResult["foundOfferId"] = foundOfferId;
 01844            }
 01845            catch (Exception ex) { clientResult["stocksError"] = ex.Message; }
 1846
 1847            // 4. Формируем payload для /v2/products/stocks
 01848            if (matchedWarehouse != null && productId > 0 && !string.IsNullOrWhiteSpace(foundOfferId))
 1849            {
 01850                var payload = new Newtonsoft.Json.Linq.JObject
 01851                {
 01852                    ["offer_id"] = foundOfferId,
 01853                    ["product_id"] = productId,
 01854                    ["stock"] = stock,
 01855                    ["warehouse_id"] = matchedWarehouse.WarehouseId
 01856                };
 01857                clientResult["payload"] = payload;
 01858                clientResult["dryRun"] = dryRun;
 1859
 01860                if (!dryRun)
 1861                {
 1862                    try
 1863                    {
 01864                        var updateResult = await apiManager.UpdateProductStocks(new List<StockItemForUpdate>
 01865                        {
 01866                            new()
 01867                            {
 01868                                OfferId = foundOfferId,
 01869                                ProductId = productId,
 01870                                Stock = stock,
 01871                                WarehouseId = matchedWarehouse.WarehouseId
 01872                            }
 01873                        });
 01874                        var row = updateResult?.Result?.FirstOrDefault();
 01875                        var errorsArr = new Newtonsoft.Json.Linq.JArray();
 01876                        foreach (var e in row?.Errors ?? new List<StockUpdateError>())
 01877                            errorsArr.Add(new Newtonsoft.Json.Linq.JObject
 01878                            {
 01879                                ["code"] = e.Code, ["message"] = e.Message
 01880                            });
 01881                        clientResult["updateResult"] = new Newtonsoft.Json.Linq.JObject
 01882                        {
 01883                            ["updated"] = row?.Updated ?? false,
 01884                            ["warehouse_id"] = row?.WarehouseId ?? 0,
 01885                            ["errors"] = errorsArr
 01886                        };
 01887                    }
 01888                    catch (Exception ex) { clientResult["updateError"] = ex.Message; }
 1889                }
 1890            }
 1891            else
 1892            {
 01893                var reasons = new List<string>();
 01894                if (matchedWarehouse == null) reasons.Add($"Склад '{warehouse}' не найден в Ozon warehouse list");
 01895                if (productId <= 0) reasons.Add("product_id не получен из /v4/product/info/stocks");
 01896                if (string.IsNullOrWhiteSpace(foundOfferId)) reasons.Add("offer_id не найден");
 01897                clientResult["cannotPush"] = string.Join("; ", reasons);
 1898            }
 1899
 01900            resultsJson.Add(clientResult);
 01901        }
 1902
 01903        var json = new Newtonsoft.Json.Linq.JObject
 01904        {
 01905            ["success"] = true,
 01906            ["article"] = article,
 01907            ["articleWithKey"] = articleWithKey,
 01908            ["candidates"] = new Newtonsoft.Json.Linq.JArray(candidates),
 01909            ["warehouse"] = warehouse,
 01910            ["stock"] = stock,
 01911            ["dryRun"] = dryRun,
 01912            ["results"] = resultsJson
 01913        };
 01914        return Content(json.ToString(Newtonsoft.Json.Formatting.Indented), "application/json");
 01915    }
 1916
 1917    [HttpGet("order-reserve-map")]
 1918    public async Task<IActionResult> GetOrderReserveMap(
 1919        [FromQuery] string? article = null,
 1920        [FromQuery] string? stores = null,
 1921        [FromQuery] string? statusIds = null)
 1922    {
 01923        var selectedStores = string.IsNullOrWhiteSpace(stores)
 01924            ? null
 01925            : stores.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
 01926        var reserveStatusIds = string.IsNullOrWhiteSpace(statusIds)
 01927            ? null
 01928            : statusIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 1929                .Select(s => int.TryParse(s, out var id) ? id : 0)
 1930                .Where(id => id > 0)
 01931                .Distinct()
 01932                .ToList();
 1933
 01934        var map = await _bitrixStockSyncReportService.GetOrderReserveMapAsync(selectedStores, article, reserveStatusIds,
 01935        return Ok(map);
 01936    }
 1937
 1938    [HttpGet("order-reserve-map/export/excel")]
 1939    public async Task<IActionResult> ExportOrderReserveMapExcel(
 1940        [FromQuery] string? article = null,
 1941        [FromQuery] string? stores = null,
 1942        [FromQuery] string? statusIds = null)
 1943    {
 01944        var selectedStores = string.IsNullOrWhiteSpace(stores)
 01945            ? null
 01946            : stores.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
 01947        var reserveStatusIds = string.IsNullOrWhiteSpace(statusIds)
 01948            ? null
 01949            : statusIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 1950                .Select(s => int.TryParse(s, out var id) ? id : 0)
 1951                .Where(id => id > 0)
 01952                .Distinct()
 01953                .ToList();
 1954
 01955        var map = await _bitrixStockSyncReportService.GetOrderReserveMapAsync(selectedStores, article, reserveStatusIds,
 01956        var bytes = BuildReserveMapExcel(map, selectedStores, article);
 01957        var fileName = $"reserve-map-{DateTime.UtcNow:yyyyMMdd-HHmm}.xlsx";
 01958        return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
 01959    }
 1960
 1961    private static byte[] BuildReserveMapExcel(OrderReserveMapDto map, List<string>? stores, string? articleFilter)
 1962    {
 01963        using var wb = new XLWorkbook();
 01964        var ws = wb.Worksheets.Add("Резерв заказов");
 1965
 01966        var headerRow = 1;
 01967        var headers = new[] { "Артикул", "Склад", "Резерв (ед.)", "Кол-во заказов", "Номера отправлений", "Детали заказо
 01968        for (var i = 0; i < headers.Length; i++)
 1969        {
 01970            var cell = ws.Cell(headerRow, i + 1);
 01971            cell.Value = headers[i];
 01972            cell.Style.Font.Bold = true;
 01973            cell.Style.Fill.BackgroundColor = XLColor.FromArgb(0xD0, 0xE4, 0xFF);
 1974        }
 1975
 01976        var row = 2;
 01977        foreach (var line in map.Lines)
 1978        {
 01979            ws.Cell(row, 1).Value = line.Article;
 01980            ws.Cell(row, 2).Value = line.Store;
 01981            ws.Cell(row, 3).Value = line.ReservedQty;
 01982            ws.Cell(row, 4).Value = line.OrderCount;
 01983            ws.Cell(row, 5).Value = line.Orders?.Count > 0
 01984                ? string.Join(", ", line.Orders.Select(o => o.ShipmentNumber ?? o.OrderNumber))
 01985                : string.Join(", ", line.OrderNumbers);
 01986            ws.Cell(row, 6).Value = string.Join(", ",
 01987                (line.Orders?.Count > 0
 01988                    ? line.Orders.Select(o =>
 01989                    {
 01990                        var num = o.ShipmentNumber ?? o.OrderNumber;
 01991                        var date = o.ProcessingDate.HasValue
 01992                            ? o.ProcessingDate.Value.ToString("dd.MM.yyyy")
 01993                            : "";
 01994                        return string.IsNullOrEmpty(date)
 01995                            ? $"{num} ({o.StatusName})"
 01996                            : $"{num} {date} ({o.StatusName})";
 01997                    })
 01998                    : line.OrderNumbers));
 01999            row++;
 2000        }
 2001
 02002        ws.Column(1).Width = 28;
 02003        ws.Column(2).Width = 10;
 02004        ws.Column(3).Width = 16;
 02005        ws.Column(4).Width = 18;
 02006        ws.Column(5).Width = 48;
 02007        ws.Column(6).Width = 60;
 2008
 02009        var infoRow = row + 1;
 02010        ws.Cell(infoRow, 1).Value = $"Итого: {map.TotalArticles} арт., {map.TotalOrders} заказов. Статусы: «Не указан», 
 02011        ws.Cell(infoRow, 1).Style.Font.Italic = true;
 02012        ws.Cell(infoRow, 1).Style.Font.FontColor = XLColor.Gray;
 2013
 02014        if (!string.IsNullOrWhiteSpace(articleFilter))
 2015        {
 02016            var filterRow = infoRow + 1;
 02017            ws.Cell(filterRow, 1).Value = $"Фильтр по артикулу: {articleFilter}";
 02018            ws.Cell(filterRow, 1).Style.Font.Italic = true;
 02019            ws.Cell(filterRow, 1).Style.Font.FontColor = XLColor.Gray;
 2020        }
 2021
 02022        if (stores?.Count > 0)
 2023        {
 02024            var storeRow = infoRow + (string.IsNullOrWhiteSpace(articleFilter) ? 1 : 2);
 02025            ws.Cell(storeRow, 1).Value = $"Склады: {string.Join(", ", stores)}";
 02026            ws.Cell(storeRow, 1).Style.Font.Italic = true;
 02027            ws.Cell(storeRow, 1).Style.Font.FontColor = XLColor.Gray;
 2028        }
 2029
 02030        using var ms = new System.IO.MemoryStream();
 02031        wb.SaveAs(ms);
 02032        return ms.ToArray();
 02033    }
 2034
 2035    [HttpGet("sync-reports/{reportId}/export/excel")]
 2036    public IActionResult ExportSyncReportExcel(
 2037        string reportId,
 2038        [FromQuery] string? article = null,
 2039        [FromQuery] List<string>? oneCStores = null,
 2040        [FromQuery] string? ozonWarehouse = null,
 2041        [FromQuery] string? action = null,
 2042        [FromQuery] List<string>? actions = null,
 2043        [FromQuery] bool onlyRequiresAction = false,
 2044        [FromQuery] bool onlyOzonAppliedSuccess = false,
 2045        [FromQuery] bool onlyOzonAppliedFailed = false,
 2046        [FromQuery] bool onlyOzonOrphan = false)
 02047        => StartSyncReportExcelExportTask(
 02048            reportId, article, oneCStores, ozonWarehouse, action, actions,
 02049            onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan);
 2050
 2051    [HttpPost("sync-reports/{reportId}/export/excel-tasks/start")]
 2052    [IgnoreAntiforgeryToken]
 2053    public IActionResult StartSyncReportExcelExportTask(
 2054        string reportId,
 2055        [FromQuery] string? article = null,
 2056        [FromQuery] List<string>? oneCStores = null,
 2057        [FromQuery] string? ozonWarehouse = null,
 2058        [FromQuery] string? action = null,
 2059        [FromQuery] List<string>? actions = null,
 2060        [FromQuery] bool onlyRequiresAction = false,
 2061        [FromQuery] bool onlyOzonAppliedSuccess = false,
 2062        [FromQuery] bool onlyOzonAppliedFailed = false,
 2063        [FromQuery] bool onlyOzonOrphan = false)
 2064    {
 02065        if (string.IsNullOrWhiteSpace(reportId))
 02066            return BadRequest(new { success = false, error = "reportId обязателен" });
 2067
 02068        var task = _channelTasks.Submit(ChannelTaskType.BitrixSyncReportExcel, new BitrixSyncReportTaskPayload
 02069        {
 02070            ReportId = reportId.Trim(),
 02071            Query = BuildSyncReportQuery(article, oneCStores, ozonWarehouse, action, actions,
 02072                onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan)
 02073        });
 2074
 02075        return Ok(ChannelTaskResponses.ToStartResponse(task));
 2076    }
 2077
 2078    [HttpGet("sync-reports/export/excel-tasks/{taskId}")]
 2079    public IActionResult GetSyncReportExcelExportTaskStatus(string taskId)
 2080    {
 02081        if (string.IsNullOrWhiteSpace(taskId))
 02082            return BadRequest(new { success = false, error = "taskId обязателен" });
 2083
 02084        if (!_channelTasks.TryGet(taskId.Trim(), out var task) || task == null)
 02085            return NotFound(new { success = false, error = "Задача не найдена" });
 2086
 02087        return Ok(ChannelTaskResponses.ToStatusResponse(task));
 2088    }
 2089
 2090    [HttpGet("sync-reports/export/excel-tasks/{taskId}/download")]
 2091    public IActionResult DownloadSyncReportExcelExportTaskResult(string taskId)
 2092    {
 02093        if (string.IsNullOrWhiteSpace(taskId))
 02094            return BadRequest("taskId обязателен");
 2095
 02096        if (!_channelTasks.TryGetCompletedFile(taskId.Trim(), out var filePath, out var fileName)
 02097            || string.IsNullOrWhiteSpace(filePath)
 02098            || string.IsNullOrWhiteSpace(fileName))
 2099        {
 02100            return NotFound("Файл ещё не готов или задача не найдена");
 2101        }
 2102
 02103        var bytes = System.IO.File.ReadAllBytes(filePath);
 02104        return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
 2105    }
 2106
 2107    [HttpPost("sync-reports/{reportId}/send-email")]
 2108    [HttpPost("sync-reports/{reportId}/send-email-tasks/start")]
 2109    [IgnoreAntiforgeryToken]
 2110    public IActionResult SendSyncReportEmail(
 2111        string reportId,
 2112        [FromQuery] string? article = null,
 2113        [FromQuery] List<string>? oneCStores = null,
 2114        [FromQuery] string? ozonWarehouse = null,
 2115        [FromQuery] string? action = null,
 2116        [FromQuery] List<string>? actions = null,
 2117        [FromQuery] bool onlyRequiresAction = false,
 2118        [FromQuery] bool onlyOzonAppliedSuccess = false,
 2119        [FromQuery] bool onlyOzonAppliedFailed = false,
 2120        [FromQuery] bool onlyOzonOrphan = false)
 2121    {
 02122        if (string.IsNullOrWhiteSpace(reportId))
 02123            return BadRequest(new { success = false, error = "reportId обязателен" });
 2124
 02125        var task = _channelTasks.Submit(ChannelTaskType.BitrixSyncReportEmail, new BitrixSyncReportTaskPayload
 02126        {
 02127            ReportId = reportId.Trim(),
 02128            Query = BuildSyncReportQuery(article, oneCStores, ozonWarehouse, action, actions,
 02129                onlyRequiresAction, onlyOzonAppliedSuccess, onlyOzonAppliedFailed, onlyOzonOrphan)
 02130        });
 2131
 02132        return Ok(ChannelTaskResponses.ToStartResponse(task));
 2133    }
 2134
 2135    private static BitrixStockSyncReportQuery BuildSyncReportQuery(
 2136        string? article,
 2137        List<string>? oneCStores,
 2138        string? ozonWarehouse,
 2139        string? action,
 2140        List<string>? actions,
 2141        bool onlyRequiresAction,
 2142        bool onlyOzonAppliedSuccess,
 2143        bool onlyOzonAppliedFailed,
 2144        bool onlyOzonOrphan)
 02145        => new()
 02146        {
 02147            Article = article,
 02148            OneCStores = oneCStores,
 02149            OzonWarehouse = ozonWarehouse,
 02150            Action = action,
 02151            Actions = actions,
 02152            OnlyRequiresAction = onlyRequiresAction,
 02153            OnlyOzonAppliedSuccess = onlyOzonAppliedSuccess,
 02154            OnlyOzonAppliedFailed = onlyOzonAppliedFailed,
 02155            OnlyOzonOrphan = onlyOzonOrphan
 02156        };
 2157
 2158    [HttpGet("sync-reports/{reportId}/apply-log")]
 2159    public async Task<IActionResult> GetApplyArticleLog(
 2160        string reportId,
 2161        [FromQuery] string? jobId    = null,
 2162        [FromQuery] string? article  = null,
 2163        [FromQuery] int     page     = 1,
 2164        [FromQuery] int     pageSize = 50)
 2165    {
 02166        if (string.IsNullOrWhiteSpace(reportId))
 02167            return BadRequest(new { success = false, error = "reportId обязателен" });
 2168
 02169        var result = await _applyArticleLogService.GetPageAsync(
 02170            reportId.Trim(), jobId?.Trim(), article?.Trim(), page, pageSize);
 02171        return Ok(new { success = true, log = result });
 02172    }
 2173
 2174    [HttpGet("sync-reports/{reportId}/apply-log/jobs")]
 2175    public async Task<IActionResult> GetApplyLogJobs(string reportId)
 2176    {
 02177        if (string.IsNullOrWhiteSpace(reportId))
 02178            return BadRequest(new { success = false, error = "reportId обязателен" });
 2179
 02180        var jobs = await _applyArticleLogService.GetJobIdsAsync(reportId.Trim());
 2181        return Ok(new { success = true, jobs = jobs.Select(j => new { jobId = j.JobId, appliedAt = j.AppliedAt }) });
 02182    }
 2183
 2184    [HttpGet("sync-reports/{reportId}/apply-log/analysis")]
 2185    public async Task<IActionResult> GetApplyLogAnalysis(
 2186        string reportId,
 2187        [FromQuery] string? jobId = null,
 2188        [FromQuery] string? article = null)
 2189    {
 02190        if (string.IsNullOrWhiteSpace(reportId))
 02191            return BadRequest(new { success = false, error = "reportId обязателен" });
 2192
 02193        var analysis = await _applyArticleLogService.GetAnalysisAsync(
 02194            reportId.Trim(),
 02195            jobId?.Trim(),
 02196            article?.Trim());
 02197        return Ok(new { success = true, analysis });
 02198    }
 2199
 2200    public class SyncReportArchiveRequest
 2201    {
 02202        public List<string> OfferIds { get; set; } = new();
 2203    }
 2204
 2205    [HttpPost("sync-reports/{reportId}/unarchive-products")]
 2206    [IgnoreAntiforgeryToken]
 2207    public async Task<IActionResult> UnarchiveReportProducts(string reportId, [FromBody] SyncReportArchiveRequest reques
 2208    {
 02209        if (string.IsNullOrWhiteSpace(reportId))
 02210            return BadRequest(new { success = false, error = "reportId обязателен" });
 02211        if (request?.OfferIds == null || request.OfferIds.Count == 0)
 02212            return BadRequest(new { success = false, error = "OfferIds обязателен" });
 2213
 2214        // Получить OzonClientId из отчёта
 02215        var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(),
 02216            new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 });
 02217        if (report == null) return NotFound(new { success = false, error = "Отчёт не найден" });
 2218
 02219        var allClients = (await _ozonClientServices.GetOzonClients())
 2220            .Where(c => c.ClientType == ClientType.OZON)
 02221            .ToList();
 02222        var reportClientIds = (report.MultiClientIds ?? new List<int>())
 2223            .Where(id => id > 0)
 02224            .Distinct()
 02225            .ToList();
 02226        if (!reportClientIds.Contains(report.OzonClientId))
 2227        {
 02228            reportClientIds.Add(report.OzonClientId);
 2229        }
 02230        var targetClients = allClients.Where(c => reportClientIds.Contains(c.Id)).ToList();
 02231        if (targetClients.Count == 0) return BadRequest(new { success = false, error = "Клиенты Ozon не найдены" });
 2232
 2233        // Получить OzonProductId по OfferIds по всем клиентам отчёта
 02234        var productIds = new List<long>();
 02235        foreach (var targetClient in targetClients)
 2236        {
 02237            if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey))
 2238                continue;
 2239
 02240            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 02241            foreach (var chunk in request.OfferIds.Chunk(100))
 2242            {
 02243                var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk.ToArray());
 02244                if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue;
 02245                foreach (var item in items)
 2246                {
 02247                    var pid = item["id"]?.Value<long>();
 02248                    if (pid is > 0) productIds.Add(pid.Value);
 2249                }
 2250            }
 02251        }
 2252
 02253        if (productIds.Count == 0) return Ok(new { success = true, unarchived = 0 });
 2254
 02255        var uniqueProductIds = productIds.Distinct().ToArray();
 02256        var unarchiveOk = false;
 02257        foreach (var targetClient in targetClients)
 2258        {
 02259            if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey))
 2260                continue;
 2261
 02262            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 02263            if (await apiManager.UnarchiveProductsAsync(uniqueProductIds))
 2264            {
 02265                unarchiveOk = true;
 2266            }
 2267        }
 02268        return Ok(new { success = unarchiveOk, unarchived = uniqueProductIds.Length });
 02269    }
 2270
 2271    [HttpPost("sync-reports/{reportId}/archive-products")]
 2272    [IgnoreAntiforgeryToken]
 2273    public async Task<IActionResult> ArchiveReportProducts(string reportId, [FromBody] SyncReportArchiveRequest request)
 2274    {
 02275        if (string.IsNullOrWhiteSpace(reportId))
 02276            return BadRequest(new { success = false, error = "reportId обязателен" });
 02277        if (request?.OfferIds == null || request.OfferIds.Count == 0)
 02278            return BadRequest(new { success = false, error = "OfferIds обязателен" });
 2279
 02280        var report = await _bitrixStockSyncReportService.GetReportAsync(reportId.Trim(),
 02281            new BitrixStockSyncReportQuery { Page = 1, PageSize = 1 });
 02282        if (report == null) return NotFound(new { success = false, error = "Отчёт не найден" });
 2283
 02284        var allClients = (await _ozonClientServices.GetOzonClients())
 2285            .Where(c => c.ClientType == ClientType.OZON)
 02286            .ToList();
 02287        var reportClientIds = (report.MultiClientIds ?? new List<int>())
 2288            .Where(id => id > 0)
 02289            .Distinct()
 02290            .ToList();
 02291        if (!reportClientIds.Contains(report.OzonClientId))
 2292        {
 02293            reportClientIds.Add(report.OzonClientId);
 2294        }
 02295        var targetClients = allClients.Where(c => reportClientIds.Contains(c.Id)).ToList();
 02296        if (targetClients.Count == 0) return BadRequest(new { success = false, error = "Клиенты Ozon не найдены" });
 2297
 02298        var productIds = new List<long>();
 02299        foreach (var targetClient in targetClients)
 2300        {
 02301            if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey))
 2302                continue;
 2303
 02304            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 02305            foreach (var chunk in request.OfferIds.Chunk(100))
 2306            {
 02307                var resp = await apiManager.GetProductInfoListAsync(offerIds: chunk.ToArray());
 02308                if (resp?["items"] is not Newtonsoft.Json.Linq.JArray items) continue;
 02309                foreach (var item in items)
 2310                {
 02311                    var pid = item["id"]?.Value<long>();
 02312                    if (pid is > 0) productIds.Add(pid.Value);
 2313                }
 2314            }
 02315        }
 2316
 02317        if (productIds.Count == 0) return Ok(new { success = true, archived = 0 });
 2318
 02319        var uniqueProductIds = productIds.Distinct().ToArray();
 02320        var archiveOk = false;
 02321        foreach (var targetClient in targetClients)
 2322        {
 02323            if (!TryGetOzonCredentials(targetClient, out var ozonClientId, out var ozonApiKey))
 2324                continue;
 2325
 02326            var apiManager = CreateOzonApiDataManager(ozonClientId, ozonApiKey);
 02327            if (await apiManager.ArchiveProductsAsync(uniqueProductIds))
 2328            {
 02329                archiveOk = true;
 2330            }
 2331        }
 02332        return Ok(new { success = archiveOk, archived = uniqueProductIds.Length });
 02333    }
 2334
 2335    /// <summary>Загружает все строки отчёта постранично и строит Excel-файл.</summary>
 2336    private Task<(byte[]? bytes, string filename)> BuildSyncReportExcelAsync(
 2337        string reportId, BitrixStockSyncReportQuery filterQuery)
 02338        => BitrixStockSyncReportExcelBuilder.BuildAsync(_bitrixStockSyncReportService, reportId, filterQuery);
 2339
 2340    [HttpPost("push-jobs/start")]
 2341    [IgnoreAntiforgeryToken]
 2342    public async Task<IActionResult> StartPushJob([FromBody] BitrixStockPushStartRequest request)
 2343    {
 02344        if (request == null || string.IsNullOrWhiteSpace(request.JobId))
 2345        {
 02346            return BadRequest(new { success = false, error = "jobId обязателен" });
 2347        }
 2348
 02349        if (request.Items == null || request.Items.Count == 0)
 2350        {
 02351            return BadRequest(new { success = false, error = "Не переданы позиции для наката" });
 2352        }
 2353
 02354        if (request.Items.Count > 500)
 2355        {
 02356            return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" });
 2357        }
 2358
 02359        foreach (var item in request.Items)
 2360        {
 02361            if (string.IsNullOrWhiteSpace(item.OzonClient) ||
 02362                string.IsNullOrWhiteSpace(item.OfferId) ||
 02363                item.ProductId <= 0 ||
 02364                item.WarehouseId <= 0)
 2365            {
 02366                return BadRequest(new { success = false, error = "Некорректные данные позиции (клиент, offer, product, с
 2367            }
 2368        }
 2369
 2370        try
 2371        {
 02372            using var lockConnection = JobStorage.Current.GetConnection();
 02373            using var distLock = lockConnection.AcquireDistributedLock(
 02374                BitrixStockPushJobService.PushLockKey,
 02375                TimeSpan.FromMilliseconds(1));
 02376        }
 02377        catch (DistributedLockTimeoutException)
 2378        {
 02379            return StatusCode(409, new { success = false, error = "Накат остатков уже выполняется. Дождитесь завершения.
 2380        }
 2381
 2382        try
 2383        {
 02384            var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 02385            var userName = User.Identity?.Name;
 2386
 02387            var execution = await _bitrixStockPushJobService.CreateExecutionAsync(
 02388                request.JobId.Trim(),
 02389                request.Items,
 02390                userId,
 02391                userName);
 2392
 02393            var hangfireJob = Job.FromExpression<IBitrixStockPushJobService>(service =>
 02394                service.ProcessPushAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None));
 02395            var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ
 2396
 02397            await _bitrixStockPushJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId);
 2398
 02399            return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id });
 2400        }
 02401        catch (Exception ex)
 2402        {
 02403            var message = ex.Message;
 02404            if (message.Contains("BitrixStockPush", StringComparison.OrdinalIgnoreCase) ||
 02405                message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase))
 2406            {
 02407                message = "Таблицы наката не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_push_jobs_tables.sq
 2408            }
 2409
 02410            return StatusCode(500, new { success = false, error = message });
 2411        }
 02412    }
 2413
 2414    [HttpGet("push-jobs")]
 2415    public async Task<IActionResult> GetPushJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
 2416    {
 02417        var (items, total) = await _bitrixStockPushJobService.GetExecutionsPageAsync(page, pageSize);
 02418        return Ok(new
 02419        {
 02420            success = true,
 02421            page,
 02422            pageSize,
 02423            total,
 2424            items = items.Select(e => new
 2425            {
 2426                e.Id,
 2427                e.JobId,
 2428                e.Status,
 2429                e.TriggerType,
 2430                e.StartedAt,
 2431                e.CompletedAt,
 2432                e.DurationMs,
 2433                e.TotalItems,
 2434                e.SuccessCount,
 2435                e.FailedCount,
 2436                e.TriggeredByUserName,
 2437                e.ErrorMessage
 2438            })
 02439        });
 02440    }
 2441
 2442    [HttpGet("push-jobs/{id:long}")]
 2443    public async Task<IActionResult> GetPushJobDetails(long id)
 2444    {
 02445        var execution = await _bitrixStockPushJobService.GetExecutionDetailsAsync(id);
 02446        if (execution == null)
 2447        {
 02448            return NotFound(new { success = false, error = "Задание не найдено" });
 2449        }
 2450
 02451        return Ok(new
 02452        {
 02453            success = true,
 02454            execution = new
 02455            {
 02456                execution.Id,
 02457                execution.JobId,
 02458                execution.HangfireJobId,
 02459                execution.Status,
 02460                execution.StartedAt,
 02461                execution.CompletedAt,
 02462                execution.DurationMs,
 02463                execution.TotalItems,
 02464                execution.SuccessCount,
 02465                execution.FailedCount,
 02466                execution.TriggeredByUserName,
 02467                execution.ErrorMessage,
 2468                items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new
 2469                {
 2470                    i.Id,
 2471                    i.Article,
 2472                    i.OfferId,
 2473                    i.ProductId,
 2474                    i.WarehouseId,
 2475                    i.WarehouseName,
 2476                    i.OzonClient,
 2477                    i.LinkedStoreName,
 2478                    i.PreviousStock,
 2479                    i.TargetStock,
 2480                    i.Status,
 2481                    i.ErrorMessage,
 2482                    i.ProcessedAt
 2483                })
 02484            }
 02485        });
 02486    }
 2487
 2488    [HttpPost("archive-jobs/start")]
 2489    [IgnoreAntiforgeryToken]
 2490    public async Task<IActionResult> StartArchiveJob([FromBody] BitrixStockArchiveStartRequest request)
 2491    {
 02492        if (request == null || string.IsNullOrWhiteSpace(request.JobId))
 2493        {
 02494            return BadRequest(new { success = false, error = "jobId обязателен" });
 2495        }
 2496
 02497        if (request.Items == null || request.Items.Count == 0)
 2498        {
 02499            return BadRequest(new { success = false, error = "Не переданы позиции для архивации" });
 2500        }
 2501
 02502        if (request.Items.Count > 500)
 2503        {
 02504            return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" });
 2505        }
 2506
 02507        foreach (var item in request.Items)
 2508        {
 02509            if (string.IsNullOrWhiteSpace(item.OzonClient) ||
 02510                string.IsNullOrWhiteSpace(item.OfferId) ||
 02511                item.OzonProductId <= 0)
 2512            {
 02513                return BadRequest(new { success = false, error = "Некорректные данные (клиент, offer, product_id Ozon)" 
 2514            }
 2515        }
 2516
 2517        try
 2518        {
 02519            using var lockConnection = JobStorage.Current.GetConnection();
 02520            using var distLock = lockConnection.AcquireDistributedLock(
 02521                BitrixStockArchiveJobService.ArchiveLockKey,
 02522                TimeSpan.FromMilliseconds(1));
 02523        }
 02524        catch (DistributedLockTimeoutException)
 2525        {
 02526            return StatusCode(409, new { success = false, error = "Архивация уже выполняется. Дождитесь завершения." });
 2527        }
 2528
 2529        try
 2530        {
 02531            var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 02532            var userName = User.Identity?.Name;
 2533
 02534            var execution = await _bitrixStockArchiveJobService.CreateExecutionAsync(
 02535                request.JobId.Trim(),
 02536                request.Items,
 02537                userId,
 02538                userName);
 2539
 02540            var hangfireJob = Job.FromExpression<IBitrixStockArchiveJobService>(service =>
 02541                service.ProcessArchiveAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None));
 02542            var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ
 2543
 02544            await _bitrixStockArchiveJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId);
 2545
 02546            return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id });
 2547        }
 02548        catch (Exception ex)
 2549        {
 02550            var message = GetFullExceptionMessage(ex);
 02551            if (message.Contains("BitrixStockArchive", StringComparison.OrdinalIgnoreCase) ||
 02552                message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) ||
 02553                message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase))
 2554            {
 02555                message = "Таблицы архивации не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_archive_jobs_tab
 2556            }
 2557
 02558            return StatusCode(500, new { success = false, error = message });
 2559        }
 02560    }
 2561
 2562    [HttpGet("archive-jobs")]
 2563    public async Task<IActionResult> GetArchiveJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
 2564    {
 02565        var (items, total) = await _bitrixStockArchiveJobService.GetExecutionsPageAsync(page, pageSize);
 02566        return Ok(new
 02567        {
 02568            success = true,
 02569            page,
 02570            pageSize,
 02571            total,
 2572            items = items.Select(e => new
 2573            {
 2574                e.Id,
 2575                e.JobId,
 2576                e.Status,
 2577                e.TriggerType,
 2578                e.StartedAt,
 2579                e.CompletedAt,
 2580                e.DurationMs,
 2581                e.TotalItems,
 2582                e.SuccessCount,
 2583                e.FailedCount,
 2584                e.TriggeredByUserName,
 2585                e.ErrorMessage
 2586            })
 02587        });
 02588    }
 2589
 2590    [HttpGet("archive-jobs/{id:long}")]
 2591    public async Task<IActionResult> GetArchiveJobDetails(long id)
 2592    {
 02593        var execution = await _bitrixStockArchiveJobService.GetExecutionDetailsAsync(id);
 02594        if (execution == null)
 2595        {
 02596            return NotFound(new { success = false, error = "Задание не найдено" });
 2597        }
 2598
 02599        return Ok(new
 02600        {
 02601            success = true,
 02602            execution = new
 02603            {
 02604                execution.Id,
 02605                execution.JobId,
 02606                execution.HangfireJobId,
 02607                execution.Status,
 02608                execution.StartedAt,
 02609                execution.CompletedAt,
 02610                execution.DurationMs,
 02611                execution.TotalItems,
 02612                execution.SuccessCount,
 02613                execution.FailedCount,
 02614                execution.TriggeredByUserName,
 02615                execution.ErrorMessage,
 2616                items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new
 2617                {
 2618                    i.Id,
 2619                    i.Article,
 2620                    i.OfferId,
 2621                    i.OzonProductId,
 2622                    i.OzonClient,
 2623                    i.WasArchived,
 2624                    i.Status,
 2625                    i.ErrorMessage,
 2626                    i.ProcessedAt
 2627                })
 02628            }
 02629        });
 02630    }
 2631
 2632    [HttpPost("unarchive-jobs/start")]
 2633    [IgnoreAntiforgeryToken]
 2634    public async Task<IActionResult> StartUnarchiveJob([FromBody] BitrixStockUnarchiveStartRequest request)
 2635    {
 02636        if (request == null || string.IsNullOrWhiteSpace(request.JobId))
 2637        {
 02638            return BadRequest(new { success = false, error = "jobId обязателен" });
 2639        }
 2640
 02641        if (request.Items == null || request.Items.Count == 0)
 2642        {
 02643            return BadRequest(new { success = false, error = "Не переданы позиции для разархивации" });
 2644        }
 2645
 02646        if (request.Items.Count > 500)
 2647        {
 02648            return BadRequest(new { success = false, error = "Максимум 500 позиций в одном задании" });
 2649        }
 2650
 02651        foreach (var item in request.Items)
 2652        {
 02653            if (string.IsNullOrWhiteSpace(item.OzonClient) ||
 02654                string.IsNullOrWhiteSpace(item.OfferId) ||
 02655                item.OzonProductId <= 0)
 2656            {
 02657                return BadRequest(new { success = false, error = "Некорректные данные (клиент, offer, product_id Ozon)" 
 2658            }
 2659        }
 2660
 2661        try
 2662        {
 02663            using var lockConnection = JobStorage.Current.GetConnection();
 02664            using var distLock = lockConnection.AcquireDistributedLock(
 02665                BitrixStockUnarchiveJobService.UnarchiveLockKey,
 02666                TimeSpan.FromMilliseconds(1));
 02667        }
 02668        catch (DistributedLockTimeoutException)
 2669        {
 02670            return StatusCode(409, new { success = false, error = "Разархивация уже выполняется. Дождитесь завершения." 
 2671        }
 2672
 2673        try
 2674        {
 02675            var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
 02676            var userName = User.Identity?.Name;
 2677
 02678            var execution = await _bitrixStockUnarchiveJobService.CreateExecutionAsync(
 02679                request.JobId.Trim(),
 02680                request.Items,
 02681                userId,
 02682                userName);
 2683
 02684            var hangfireJob = Job.FromExpression<IBitrixStockUnarchiveJobService>(service =>
 02685                service.ProcessUnarchiveAsync(execution.Id, request.JobId.Trim(), true, CancellationToken.None));
 02686            var hangfireJobId = new BackgroundJobClient().Create(hangfireJob, new EnqueuedState(HangfireExtensions.MainQ
 2687
 02688            await _bitrixStockUnarchiveJobRepository.UpdateHangfireJobIdAsync(execution.Id, hangfireJobId);
 2689
 02690            return Ok(new { success = true, jobId = request.JobId.Trim(), executionId = execution.Id });
 2691        }
 02692        catch (Exception ex)
 2693        {
 02694            var message = GetFullExceptionMessage(ex);
 02695            if (message.Contains("BitrixStockUnarchive", StringComparison.OrdinalIgnoreCase) ||
 02696                message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) ||
 02697                message.Contains("Unknown table", StringComparison.OrdinalIgnoreCase))
 2698            {
 02699                message = "Таблицы разархивации не созданы в БД. Выполните Scripts/Sql/create_bitrix_stock_unarchive_job
 2700            }
 2701
 02702            return StatusCode(500, new { success = false, error = message });
 2703        }
 02704    }
 2705
 2706    [HttpGet("unarchive-jobs")]
 2707    public async Task<IActionResult> GetUnarchiveJobs([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
 2708    {
 02709        var (items, total) = await _bitrixStockUnarchiveJobService.GetExecutionsPageAsync(page, pageSize);
 02710        return Ok(new
 02711        {
 02712            success = true,
 02713            page,
 02714            pageSize,
 02715            total,
 2716            items = items.Select(e => new
 2717            {
 2718                e.Id,
 2719                e.JobId,
 2720                e.Status,
 2721                e.TriggerType,
 2722                e.StartedAt,
 2723                e.CompletedAt,
 2724                e.DurationMs,
 2725                e.TotalItems,
 2726                e.SuccessCount,
 2727                e.FailedCount,
 2728                e.TriggeredByUserName,
 2729                e.ErrorMessage
 2730            })
 02731        });
 02732    }
 2733
 2734    [HttpGet("unarchive-jobs/{id:long}")]
 2735    public async Task<IActionResult> GetUnarchiveJobDetails(long id)
 2736    {
 02737        var execution = await _bitrixStockUnarchiveJobService.GetExecutionDetailsAsync(id);
 02738        if (execution == null)
 2739        {
 02740            return NotFound(new { success = false, error = "Задание не найдено" });
 2741        }
 2742
 02743        return Ok(new
 02744        {
 02745            success = true,
 02746            execution = new
 02747            {
 02748                execution.Id,
 02749                execution.JobId,
 02750                execution.HangfireJobId,
 02751                execution.Status,
 02752                execution.StartedAt,
 02753                execution.CompletedAt,
 02754                execution.DurationMs,
 02755                execution.TotalItems,
 02756                execution.SuccessCount,
 02757                execution.FailedCount,
 02758                execution.TriggeredByUserName,
 02759                execution.ErrorMessage,
 2760                items = execution.Items.OrderBy(i => i.SortOrder).Select(i => new
 2761                {
 2762                    i.Id,
 2763                    i.Article,
 2764                    i.OfferId,
 2765                    i.OzonProductId,
 2766                    i.OzonClient,
 2767                    i.IsAutoArchived,
 2768                    i.IsManualArchived,
 2769                    i.WasInArchive,
 2770                    i.Status,
 2771                    i.ErrorMessage,
 2772                    i.ProcessedAt
 2773                })
 02774            }
 02775        });
 02776    }
 2777
 2778    [HttpPost("auto-schedules/parse-filter")]
 2779    [IgnoreAntiforgeryToken]
 2780    public async Task<IActionResult> ParseAutoScheduleFilter([FromBody] BitrixStockAutoParseFilterRequest request)
 2781    {
 02782        if (request.OzonClientId <= 0)
 2783        {
 02784            return BadRequest(new { success = false, error = "Выберите клиента Ozon" });
 2785        }
 2786
 02787        var action = string.IsNullOrWhiteSpace(request.ActionType)
 02788            ? BitrixStockAutoActionTypes.Clear
 02789            : request.ActionType.Trim();
 02790        var parseRequest = new BitrixStockAutoScheduleRequest
 02791        {
 02792            OzonClientId = request.OzonClientId,
 02793            ActionType = action,
 02794            FilterQuery = request.QueryString,
 02795            SelectedStoreTitles = request.SelectedStoreTitles
 02796        };
 02797        var filterJson = await BuildAutoScheduleFilterJsonAsync(parseRequest, action);
 02798        var filter = BitrixStockInventoryService.DeserializeFilter(filterJson);
 2799
 02800        return Ok(new
 02801        {
 02802            success = true,
 02803            filterJson,
 02804            description = BitrixStockAutoFilterFactory.DescribeFilter(filter, action)
 02805        });
 02806    }
 2807
 2808    [HttpPost("auto-schedules/preview")]
 2809    [IgnoreAntiforgeryToken]
 2810    public async Task<IActionResult> PreviewAutoSchedule([FromBody] BitrixStockAutoScheduleRequest request)
 2811    {
 02812        var error = ValidateAutoScheduleRequest(request);
 02813        if (error != null)
 2814        {
 02815            return BadRequest(new { success = false, error });
 2816        }
 2817
 2818        try
 2819        {
 02820            var entity = await MapAutoScheduleEntityAsync(request);
 02821            var collected = await _bitrixStockInventoryService.CollectForScheduleAsync(entity);
 02822            var isClear = string.Equals(entity.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgn
 02823            var items = isClear ? collected.ClearItems.Count : collected.PushItems.Count;
 02824            return Ok(new
 02825            {
 02826                success = true,
 02827                productsScanned = collected.ProductsScanned,
 02828                itemsToProcess = items,
 02829                warning = collected.Warning
 02830            });
 2831        }
 02832        catch (Exception ex)
 2833        {
 02834            return StatusCode(500, new { success = false, error = ex.Message });
 2835        }
 02836    }
 2837
 2838    [HttpGet("auto-schedules")]
 2839    public async Task<IActionResult> GetAutoSchedules()
 2840    {
 02841        var list = await _bitrixStockAutoScheduleService.GetAllAsync();
 02842        return Ok(new
 02843        {
 02844            success = true,
 2845            items = list.Select(s => MapAutoScheduleDto(s))
 02846        });
 02847    }
 2848
 2849    [HttpPost("auto-schedules")]
 2850    public async Task<IActionResult> CreateAutoSchedule([FromBody] BitrixStockAutoScheduleRequest request)
 2851    {
 02852        var error = ValidateAutoScheduleRequest(request);
 02853        if (error != null)
 2854        {
 02855            return BadRequest(new { success = false, error });
 2856        }
 2857
 2858        try
 2859        {
 02860            var id = await _bitrixStockAutoScheduleService.CreateAsync(await MapAutoScheduleEntityAsync(request));
 02861            return Ok(new { success = true, id });
 2862        }
 02863        catch (Exception ex)
 2864        {
 02865            return StatusCode(500, new { success = false, error = ex.Message });
 2866        }
 02867    }
 2868
 2869    [HttpPut("auto-schedules/{id:int}")]
 2870    public async Task<IActionResult> UpdateAutoSchedule(int id, [FromBody] BitrixStockAutoScheduleRequest request)
 2871    {
 02872        var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id);
 02873        if (existing == null)
 2874        {
 02875            return NotFound(new { success = false, error = "Расписание не найдено" });
 2876        }
 2877
 02878        request.Id = id;
 02879        var error = ValidateAutoScheduleRequest(request);
 02880        if (error != null)
 2881        {
 02882            return BadRequest(new { success = false, error });
 2883        }
 2884
 2885        try
 2886        {
 02887            var entity = await MapAutoScheduleEntityAsync(request);
 02888            entity.Id = id;
 02889            entity.CreatedAt = existing.CreatedAt;
 02890            await _bitrixStockAutoScheduleService.UpdateAsync(entity);
 02891            return Ok(new { success = true });
 2892        }
 02893        catch (Exception ex)
 2894        {
 02895            return StatusCode(500, new { success = false, error = ex.Message });
 2896        }
 02897    }
 2898
 2899    [HttpDelete("auto-schedules/{id:int}")]
 2900    public async Task<IActionResult> DeleteAutoSchedule(int id)
 2901    {
 02902        var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id);
 02903        if (existing == null)
 2904        {
 02905            return NotFound(new { success = false, error = "Расписание не найдено" });
 2906        }
 2907
 02908        await _bitrixStockAutoScheduleService.DeleteAsync(id);
 02909        return Ok(new { success = true });
 02910    }
 2911
 2912    [HttpPost("auto-schedules/{id:int}/trigger")]
 2913    [IgnoreAntiforgeryToken]
 2914    public async Task<IActionResult> TriggerAutoSchedule(int id, [FromBody] BitrixStockAutoTriggerRequest? request)
 2915    {
 02916        if (request == null || string.IsNullOrWhiteSpace(request.JobId))
 2917        {
 02918            return BadRequest(new { success = false, error = "jobId обязателен" });
 2919        }
 2920
 02921        var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id);
 02922        if (existing == null)
 2923        {
 02924            return NotFound(new { success = false, error = "Расписание не найдено" });
 2925        }
 2926
 02927        var isClear = string.Equals(existing.ActionType, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnor
 02928        var lockKey = isClear ? BitrixStockClearJobService.ClearLockKey : BitrixStockPushJobService.PushLockKey;
 2929
 2930        try
 2931        {
 02932            using var lockConnection = JobStorage.Current.GetConnection();
 02933            using var distLock = lockConnection.AcquireDistributedLock(lockKey, TimeSpan.FromMilliseconds(1));
 02934        }
 02935        catch (DistributedLockTimeoutException)
 2936        {
 02937            return StatusCode(409, new { success = false, error = "Операция Ozon уже выполняется. Дождитесь завершения."
 2938        }
 2939
 02940        var jobId = request.JobId.Trim();
 02941        var userName = User.Identity?.Name;
 2942
 02943        await _bitrixStockAutoScheduleService.TriggerManualAsync(id, jobId, userName);
 2944
 02945        return Ok(new
 02946        {
 02947            success = true,
 02948            jobId,
 02949            scheduleId = id,
 02950            actionType = existing.ActionType,
 02951            scheduleName = existing.Name
 02952        });
 02953    }
 2954
 2955    [HttpGet("auto-schedules/{id:int}/runs")]
 2956    public async Task<IActionResult> GetAutoScheduleRuns(int id)
 2957    {
 02958        var existing = await _bitrixStockAutoScheduleService.GetByIdAsync(id);
 02959        if (existing == null)
 2960        {
 02961            return NotFound(new { success = false, error = "Расписание не найдено" });
 2962        }
 2963
 02964        var runs = await _bitrixStockAutoScheduleService.GetRunsAsync(id);
 02965        return Ok(new
 02966        {
 02967            success = true,
 2968            items = runs.Select(r => new
 2969            {
 2970                r.Id,
 2971                r.Status,
 2972                r.StartedAt,
 2973                r.CompletedAt,
 2974                r.CandidatesFound,
 2975                r.ItemsQueued,
 2976                r.ClearExecutionId,
 2977                r.PushExecutionId,
 2978                r.ErrorMessage,
 2979                r.RunLog
 2980            })
 02981        });
 02982    }
 2983
 2984    private static string? ValidateAutoScheduleRequest(BitrixStockAutoScheduleRequest request)
 2985    {
 02986        if (string.IsNullOrWhiteSpace(request.Name))
 2987        {
 02988            return "Укажите название задачи";
 2989        }
 2990
 02991        if (request.OzonClientId <= 0)
 2992        {
 02993            return "Выберите клиента Ozon";
 2994        }
 2995
 02996        var action = request.ActionType?.Trim();
 02997        if (!string.Equals(action, BitrixStockAutoActionTypes.Clear, StringComparison.OrdinalIgnoreCase) &&
 02998            !string.Equals(action, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase))
 2999        {
 03000            return "Тип задачи: Clear или Push";
 3001        }
 3002
 3003        try
 3004        {
 03005            Cronos.CronExpression.Parse(request.CronExpression.Trim());
 03006        }
 03007        catch
 3008        {
 03009            return "Некорректное cron-выражение";
 3010        }
 3011
 03012        return null;
 03013    }
 3014
 3015    [HttpGet("auto-schedules/default-filter")]
 3016    public async Task<IActionResult> GetAutoScheduleDefaultFilter(
 3017        [FromQuery] string actionType,
 3018        [FromQuery] int ozonClientId)
 3019    {
 03020        if (ozonClientId <= 0)
 3021        {
 03022            return BadRequest(new { success = false, error = "Выберите клиента Ozon" });
 3023        }
 3024
 03025        var action = string.Equals(actionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase)
 03026            ? BitrixStockAutoActionTypes.Push
 03027            : BitrixStockAutoActionTypes.Clear;
 03028        var activeStores = await _repository.GetActiveStoresAsync();
 03029        var filter = BitrixStockAutoFilterFactory.BuildForSave(action, ozonClientId, activeStores, null, null);
 03030        return Ok(new
 03031        {
 03032            success = true,
 03033            filterJson = BitrixStockFilterSerializer.ToJson(filter),
 03034            description = BitrixStockAutoFilterFactory.DescribeFilter(filter, action)
 03035        });
 03036    }
 3037
 3038    private async Task<string> BuildAutoScheduleFilterJsonAsync(BitrixStockAutoScheduleRequest request, string actionTyp
 3039    {
 03040        var activeStores = await _repository.GetActiveStoresAsync();
 03041        var filter = BitrixStockAutoFilterFactory.BuildForSave(
 03042            actionType,
 03043            request.OzonClientId,
 03044            activeStores,
 03045            request.SelectedStoreTitles,
 03046            userExtras: null);
 3047
 03048        return BitrixStockFilterSerializer.ToJson(filter);
 03049    }
 3050
 3051    private async Task<BitrixStockAutoSchedule> MapAutoScheduleEntityAsync(BitrixStockAutoScheduleRequest request)
 3052    {
 03053        var action = string.Equals(request.ActionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCa
 03054            ? BitrixStockAutoActionTypes.Push
 03055            : BitrixStockAutoActionTypes.Clear;
 03056        var filterJson = await BuildAutoScheduleFilterJsonAsync(request, action);
 3057
 03058        return new BitrixStockAutoSchedule
 03059        {
 03060            Id = request.Id,
 03061            Name = request.Name.Trim(),
 03062            ActionType = action,
 03063            OzonClientId = request.OzonClientId,
 03064            CronExpression = request.CronExpression.Trim(),
 03065            IsActive = request.IsActive,
 03066            MaxItemsPerRun = request.MaxItemsPerRun <= 0 ? 500 : Math.Min(request.MaxItemsPerRun, 500),
 03067            FilterJson = filterJson
 03068        };
 03069    }
 3070
 03071    private static object MapAutoScheduleDto(BitrixStockAutoSchedule s) => new
 03072    {
 03073        s.Id,
 03074        s.Name,
 03075        s.ActionType,
 03076        actionLabel = string.Equals(s.ActionType, BitrixStockAutoActionTypes.Push, StringComparison.OrdinalIgnoreCase)
 03077            ? "Накат"
 03078            : "Обнуление",
 03079        s.OzonClientId,
 03080        ozonClientName = s.OzonClient?.Name,
 03081        s.CronExpression,
 03082        s.IsActive,
 03083        s.MaxItemsPerRun,
 03084        filterJson = s.FilterJson,
 03085        filterDescription = DescribeStoredScheduleFilter(s),
 03086        s.LastRunAt,
 03087        s.NextRunAt,
 03088        s.CreatedAt,
 03089        s.UpdatedAt
 03090    };
 3091
 3092    private static string DescribeStoredScheduleFilter(BitrixStockAutoSchedule schedule)
 3093    {
 03094        var filter = BitrixStockAutoFilterFactory.ResolveForSchedule(schedule, null);
 03095        return BitrixStockAutoFilterFactory.DescribeFilter(filter, schedule.ActionType);
 3096    }
 3097
 3098    private async Task<List<RemainingStockBitrix>> ApplyFiltersAsync(List<RemainingStockBitrix> items,
 3099        RemainingStockFilter filter)
 3100    {
 03101        if (!items.Any()) return new List<RemainingStockBitrix>();
 03102        var totalSw = Stopwatch.StartNew();
 3103        // suppliers & prefixes
 03104        var suppliers = items
 3105            .Where(r => !string.IsNullOrWhiteSpace(r.Supplier))
 3106            .Select(r => r.Supplier!)
 03107            .Distinct()
 03108            .ToList();
 3109        Dictionary<string, string?> prefixes;
 3110        try
 3111        {
 03112            prefixes = await _etProducerRepository.GetMarketPrefixesByNamesAsync(suppliers);
 03113        }
 03114        catch (Exception)
 3115        {
 3116            prefixes = suppliers.ToDictionary(s => s, _ => (string?)null);
 03117            AppendPerformanceNotice("Справочник префиксов поставщиков (EtProducers) недоступен — страница без MarketPref
 03118        }
 3119
 3120        var productIds = items.Select(x => x.ProductId).ToList();
 3121        Dictionary<int, PriceHistory> priceHistories;
 3122        try
 3123        {
 03124            priceHistories = await _priceHistoryService.GetLastByBitrixIdsAsync(productIds);
 03125        }
 03126        catch (Exception)
 3127        {
 03128            priceHistories = new Dictionary<int, PriceHistory>();
 03129            AppendPerformanceNotice("История цен временно недоступна — фильтры по RegistrationPrice могут не сработать."
 03130        }
 3131
 03132        foreach (var item in items)
 3133        {
 03134            if (!string.IsNullOrWhiteSpace(item.Supplier) && prefixes.TryGetValue(item.Supplier, out var prefix))
 03135                item.MarketPrefix = prefix;
 3136
 03137            if (priceHistories.TryGetValue(item.ProductId, out var priceHistory))
 03138                item.PriceHistory = priceHistory;
 3139        }
 3140
 3141        // фильтры по RegistrationPrice
 03142        if (filter.RegistrationPriceFrom.HasValue)
 03143            items = items.Where(x => x.PriceHistory != null &&
 03144                                     x.PriceHistory.RegistrationPrice >= filter.RegistrationPriceFrom.Value).ToList();
 3145
 03146        if (filter.RegistrationPriceTo.HasValue)
 03147            items = items.Where(x => x.PriceHistory != null &&
 03148                                     x.PriceHistory.RegistrationPrice <= filter.RegistrationPriceTo.Value).ToList();
 3149
 3150        // OzonWarehouses
 03151        if (filter.LoadOzonWarehouses)
 3152        {
 03153            var ozonSw = Stopwatch.StartNew();
 03154            items = await LoadOzonWarehousesOptimizedAsync(items, filter);
 03155            ozonSw.Stop();
 3156
 3157            // Остатки в Ozon меняются только вручную (кнопки наката/обнуления), не при загрузке страницы.
 03158            foreach (var client in _ozonClients)
 3159            {
 03160                BitrixOzonStockLinkage.PruneUnlistedOzonProductStores(items, client.Name);
 3161            }
 3162
 03163            if (!string.IsNullOrEmpty(filter.OzonStoreTitle))
 03164                items = items.Where(x => x.OzonStores.Any(s =>
 03165                    s.Title.Contains(filter.OzonStoreTitle, StringComparison.OrdinalIgnoreCase))).ToList();
 3166
 03167            if (filter.OzonAmountFrom.HasValue)
 03168                items = items.Where(x => x.OzonStores.Any(s => s.Amount >= filter.OzonAmountFrom.Value)).ToList();
 3169
 03170            if (filter.OzonAmountTo.HasValue)
 03171                items = items.Where(x => x.OzonStores.Any(s => s.Amount <= filter.OzonAmountTo.Value)).ToList();
 3172
 03173            if (filter.DiffOzonWarehouses)
 3174            {
 3175                items = items.Where(x => x.OzonStores.Any(oz =>
 3176                {
 3177                    var ozonAmount = oz.OzonPresentOnOzon ?? (int)Math.Round(oz.Amount);
 3178
 3179                    if (oz.LinkStores?.Any() == true)
 3180                    {
 3181                        var bitrixAmount = BitrixOzonStockLinkage.GetLinkedOneCQuantity(x, oz.LinkStores);
 3182                        return bitrixAmount != ozonAmount;
 3183                    }
 3184
 3185                    // Разница считается только по связанным складам Ozon↔1C.
 3186                    // Несвязанные склады (links пустой) не должны включать строку в Diff-фильтр.
 3187                    return false;
 3188                })).ToList();
 3189            }
 3190
 03191        }
 3192
 3193        // OzonPriceIndexes
 03194        if (filter.LoadOzonPriceIndexes)
 3195        {
 03196            items = await LoadOzonPriceIndexesAsync(items, filter.LoadOzonPriceIndexes);
 3197
 03198            if (!string.IsNullOrEmpty(filter.PriceIndexColor))
 03199                items = items.Where(x => x.OzonPriceIndexes != null &&
 03200                                         x.OzonPriceIndexes.Any(i =>
 03201                                             string.Equals(i.ColorIndex, filter.PriceIndexColor,
 03202                                                 StringComparison.OrdinalIgnoreCase)))
 03203                    .ToList();
 3204
 03205            if (filter.PriceIndexValueFrom.HasValue || filter.PriceIndexValueTo.HasValue)
 03206                items = items.Where(x => x.OzonPriceIndexes != null &&
 03207                                         x.OzonPriceIndexes.Any(i =>
 03208                                         {
 03209                                             var value = i.OzonIndex?.PriceIndexValue;
 03210                                             if (!value.HasValue) return false;
 03211                                             bool match = true;
 03212                                             if (filter.PriceIndexValueFrom.HasValue)
 03213                                                 match &= value.Value >= filter.PriceIndexValueFrom.Value;
 03214                                             if (filter.PriceIndexValueTo.HasValue)
 03215                                                 match &= value.Value <= filter.PriceIndexValueTo.Value;
 03216                                             return match;
 03217                                         })).ToList();
 3218
 03219            if (filter.MinPriceFrom.HasValue || filter.MinPriceTo.HasValue)
 03220                items = items.Where(x => x.OzonPriceIndexes != null &&
 03221                                         x.OzonPriceIndexes.Any(i =>
 03222                                         {
 03223                                             var minPrice = i.OzonIndex?.MinPrice;
 03224                                             if (!minPrice.HasValue) return false;
 03225                                             bool match = true;
 03226                                             if (filter.MinPriceFrom.HasValue)
 03227                                                 match &= minPrice.Value >= filter.MinPriceFrom.Value;
 03228                                             if (filter.MinPriceTo.HasValue)
 03229                                                 match &= minPrice.Value <= filter.MinPriceTo.Value;
 03230                                             return match;
 03231                                         })).ToList();
 3232
 03233            if (filter.RedOzonPriceIndexes)
 3234                items = items.Where(x => x.OzonPriceIndexes != null &&
 3235                                         x.OzonPriceIndexes.Any(p =>
 3236                                             p.ColorIndex == "RED" && (p.OzonIndex?.MinPrice ?? 0) != 0))
 03237                    .ToList();
 3238        }
 03239        totalSw.Stop();
 03240        return items;
 03241    }
 3242
 3243    private static string GetFullExceptionMessage(Exception ex)
 3244    {
 03245        var sb = new StringBuilder();
 03246        for (var e = ex; e != null; e = e.InnerException)
 3247        {
 03248            if (sb.Length > 0)
 3249            {
 03250                sb.Append(" | ");
 3251            }
 3252
 03253            sb.Append(e.Message);
 3254        }
 3255
 03256        return sb.ToString();
 3257    }
 3258
 3259    private async Task<List<RemainingStockBitrix>> LoadOzonWarehousesOptimizedAsync(
 3260        List<RemainingStockBitrix> items,
 3261        RemainingStockFilter filter)
 3262    {
 03263        var allArticles = items
 3264            .Select(x => x.ArticleWithKey)
 3265            .Where(x => !string.IsNullOrWhiteSpace(x))
 03266            .Distinct(StringComparer.OrdinalIgnoreCase)
 03267            .ToArray();
 3268
 03269        if (IsWideOzonBrowsing(filter) && allArticles.Length > MaxArticlesForOzonStocksLoad)
 3270        {
 03271            _ozonLoadWarnings.Add(
 03272                $"Остатки Ozon загружены только для первых {MaxArticlesForOzonStocksLoad} из {allArticles.Length} артику
 03273            allArticles = allArticles.Take(MaxArticlesForOzonStocksLoad).ToArray();
 3274        }
 3275
 03276        var clientWarehouseCache = new ConcurrentDictionary<string, List<WarehouseOzon>>();
 03277        var bitrixStoresToEnsure = GetBitrixStoreNamesForMapping(items, filter);
 3278
 03279        foreach (var client in _ozonClients)
 3280        {
 03281            await ProcessClientWarehousesAsync(client, items, allArticles, clientWarehouseCache, filter, bitrixStoresToE
 3282        }
 3283
 03284        return items;
 03285    }
 3286
 3287    private static HashSet<string> GetBitrixStoreNamesForMapping(
 3288        List<RemainingStockBitrix> items,
 3289        RemainingStockFilter filter)
 3290    {
 03291        var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 3292
 03293        if (filter.SelectedStoreTitles?.Any() == true)
 3294        {
 03295            foreach (var title in filter.SelectedStoreTitles)
 3296            {
 03297                if (!string.IsNullOrWhiteSpace(title))
 3298                {
 03299                    names.Add(title.Trim());
 3300                }
 3301            }
 3302        }
 3303        else
 3304        {
 03305            foreach (var storeTitle in items
 03306                         .SelectMany(i => i.Stores ?? Enumerable.Empty<StockByStore>())
 03307                         .Select(s => s.Title)
 03308                         .Where(t => !string.IsNullOrWhiteSpace(t)))
 3309            {
 03310                names.Add(storeTitle.Trim());
 3311            }
 3312        }
 3313
 03314        return names;
 3315    }
 3316
 3317    private static HashSet<string> GetAllowedBitrixStoreNames(
 3318        RemainingStockFilter filter,
 3319        List<RemainingStockBitrix> items)
 3320    {
 03321        return GetBitrixStoreNamesForMapping(items, filter);
 3322    }
 3323
 3324    private async Task<ConcurrentDictionary<string, List<WarehouseOzon>>> PreloadAllWarehousesAsync()
 3325    {
 03326        var cache = new ConcurrentDictionary<string, List<WarehouseOzon>>();
 3327
 03328        var warehouseTasks = _ozonClients
 03329            .Where(HasOzonCredentials)
 03330            .Select(async client =>
 03331        {
 03332            if (!TryGetOzonCredentials(client, out var clientId, out var apiKey))
 03333                return;
 03334
 03335            var mgr = CreateOzonApiDataManager(clientId, apiKey);
 03336            var warehouses = await GetOrCreateWarehouseCache(clientId, mgr);
 03337            cache[clientId] = warehouses;
 03338        });
 3339
 03340        await Task.WhenAll(warehouseTasks);
 03341        return cache;
 03342    }
 3343
 3344    private async Task ProcessClientWarehousesAsync(
 3345        OzonClient client,
 3346        List<RemainingStockBitrix> items,
 3347        string[] allArticles,
 3348        ConcurrentDictionary<string, List<WarehouseOzon>> warehouseCache,
 3349        RemainingStockFilter filter,
 3350        HashSet<string> bitrixStoresToEnsure)
 3351    {
 03352        var totalSw = Stopwatch.StartNew();
 03353        if (!TryConfigureOzonApiClient(client))
 03354            return;
 3355
 03356        var clientId = client.DecryptClientId!;
 03357        if (!warehouseCache.TryGetValue(clientId, out var warehouseList))
 3358        {
 03359            warehouseList = await GetOrCreateWarehouseCache(clientId);
 03360            warehouseCache[clientId] = warehouseList;
 3361        }
 3362
 03363        if (bitrixStoresToEnsure.Count > 0)
 3364        {
 03365            await EnsureWarehouseMappingsInternalAsync(client, bitrixStoresToEnsure.ToList(), warehouseList);
 3366        }
 3367
 03368        var stocksSw = Stopwatch.StartNew();
 03369        var stocksCacheKey =
 03370            $"OzonProductStocks:{clientId}:{BuildArticlesCacheKeyPart(allArticles)}";
 03371        var stocksCacheHit = _memoryCache.TryGetValue(stocksCacheKey, out JObject? allStocks);
 03372        if (!stocksCacheHit)
 3373        {
 3374            try
 3375            {
 03376                allStocks = await _ozonApiDataManager.GetProductStocks(allArticles);
 03377                if (allStocks != null)
 3378                {
 03379                    _memoryCache.Set(stocksCacheKey, allStocks, new MemoryCacheEntryOptions()
 03380                        .SetAbsoluteExpiration(TimeSpan.FromMinutes(3))
 03381                        .SetSlidingExpiration(TimeSpan.FromMinutes(1)));
 3382                }
 03383            }
 03384            catch (Exception ex) when (IsOzonLoadFailure(ex))
 3385            {
 03386                _ozonLoadWarnings.Add(
 03387                    $"Не удалось загрузить остатки Ozon для «{client.Name}»: {DescribeOzonLoadFailure(ex)}");
 03388                stocksSw.Stop();
 03389                return;
 3390            }
 3391        }
 03392        stocksSw.Stop();
 3393
 03394        if (allStocks?["items"] == null) return;
 3395
 03396        var allClientMappings = await _warehouseMappingDataServices.GetByClientIdAsync(client.Id);
 03397        var allowedBitrixStores = GetAllowedBitrixStoreNames(filter, items);
 03398        var perfCounters = new OzonPerfCounters();
 03399        var itemTasksSw = Stopwatch.StartNew();
 03400        foreach (var item in items)
 3401        {
 03402            await ProcessItemWarehousesAsync(
 03403                item, client, allStocks, warehouseCache, allClientMappings, allowedBitrixStores, perfCounters);
 3404        }
 3405
 03406        itemTasksSw.Stop();
 03407        totalSw.Stop();
 03408    }
 3409
 3410    private async Task ProcessItemWarehousesAsync(
 3411        RemainingStockBitrix item,
 3412        OzonClient client,
 3413        JObject allStocks,
 3414        ConcurrentDictionary<string, List<WarehouseOzon>> warehouseCache,
 3415        List<WarehouseMapping> clientMappings,
 3416        HashSet<string> allowedBitrixStores,
 3417        OzonPerfCounters perfCounters)
 3418    {
 03419        Interlocked.Increment(ref perfCounters.ItemsChecked);
 03420        if (clientMappings.Count == 0)
 3421        {
 03422            return;
 3423        }
 3424
 03425        if (!HasOzonCredentials(client))
 03426            return;
 3427
 03428        var clientId = client.DecryptClientId!;
 03429        if (!warehouseCache.TryGetValue(clientId, out var warehouseList))
 3430        {
 03431            warehouseList = await GetOrCreateWarehouseCache(clientId);
 03432            warehouseCache[clientId] = warehouseList;
 3433        }
 3434
 03435        JToken? warehouseInfo = null;
 03436        var stocksProductId = 0L;
 03437        var itemStocks = BitrixOzonStockLinkage.FindStocksForArticle(allStocks, item);
 03438        if (itemStocks != null)
 3439        {
 03440            Interlocked.Increment(ref perfCounters.ItemStocksFound);
 03441            stocksProductId = BitrixOzonStockLinkage.ExtractProductIdFromStocks(itemStocks);
 3442
 03443            var warehouseInfoSw = Stopwatch.StartNew();
 03444            warehouseInfo = await _ozonApiDataManager.GetWarehouseInfo(
 03445                itemStocks, item.ArticleWithKey, client, warehouseList);
 03446            warehouseInfoSw.Stop();
 03447            Interlocked.Increment(ref perfCounters.WarehouseInfoCalls);
 03448            Interlocked.Add(ref perfCounters.WarehouseInfoMs, warehouseInfoSw.ElapsedMilliseconds);
 03449        }
 3450
 03451        BitrixOzonStockLinkage.ApplyMappedStocks(
 03452            item,
 03453            client,
 03454            warehouseInfo,
 03455            clientMappings,
 03456            warehouseList,
 03457            allowedBitrixStores,
 03458            item.ArticleWithKey,
 03459            stocksProductId);
 03460    }
 3461
 3462
 3463    private async Task<List<RemainingStockBitrix>> LoadOzonPriceIndexesAsync(
 3464        List<RemainingStockBitrix> items,
 3465        bool loadOzonPriceIndexes)
 3466    {
 03467        if (!loadOzonPriceIndexes || items == null || !items.Any())
 03468            return items ?? new List<RemainingStockBitrix>();
 3469
 3470        // Получаем уникальные артикулы
 03471        var articles = items
 3472            .Where(x => !string.IsNullOrEmpty(x.ArticleWithKey))
 3473            .Select(x => x.ArticleWithKey)
 03474            .Distinct()
 03475            .ToArray();
 3476
 03477        if (!articles.Any())
 03478            return items;
 3479
 03480        var allPriceIndexes = new ConcurrentBag<(string Article, OzonPriceIndexes Indexes, string ClientName)>();
 3481
 03482        var tasks = _ozonClients.Select(async client =>
 03483        {
 03484            if (!TryGetOzonCredentials(client, out var clientId, out var apiKey))
 03485                return;
 03486
 03487            var mgr = CreateOzonApiDataManager(clientId, apiKey);
 03488
 03489            try
 03490            {
 03491                // Получаем цены по всем артикулам сразу (по 500 штук за раз)
 03492                var priceResponse = await mgr.GetProductPricesByArticles(articles);
 03493
 03494                if (priceResponse?["items"] is not JArray priceItems)
 03495                    return;
 03496
 03497                foreach (var item in priceItems)
 03498                {
 03499                    var offerId = item["offer_id"]?.ToString();
 03500                    var priceIndexes = item["price_indexes"];
 03501                    if (string.IsNullOrEmpty(offerId) || priceIndexes == null)
 03502                        continue;
 03503
 03504                    var indexes = ParsePriceIndexes(priceIndexes, client.Name);
 03505                    if (indexes != null)
 03506                    {
 03507                        allPriceIndexes.Add((offerId, indexes, client.Name));
 03508                    }
 03509                }
 03510            }
 03511            catch (Exception)
 03512            {
 03513                // Логирование можно добавить
 03514            }
 03515        });
 3516
 03517        await Task.WhenAll(tasks);
 3518
 3519        // Присоединяем индексы к элементам
 03520        var articleLookup = allPriceIndexes
 3521            .GroupBy(x => x.Article)
 3522            .ToDictionary(g => g.Key, g => g.Select(x => x.Indexes).ToList());
 3523
 03524        foreach (var item in items)
 3525        {
 03526            if (string.IsNullOrWhiteSpace(item.ArticleWithKey))
 3527                continue;
 3528
 03529            if (articleLookup.TryGetValue(item.ArticleWithKey, out var indexes))
 3530            {
 03531                item.OzonPriceIndexes = indexes;
 3532            }
 3533        }
 3534
 03535        return items;
 03536    }
 3537
 3538
 3539    private OzonPriceIndexes? ParsePriceIndexes(JToken priceIndexes, string clientName)
 3540    {
 03541        if (priceIndexes == null)
 03542            return null;
 3543
 3544        try
 3545        {
 03546            var indexes = new OzonPriceIndexes
 03547            {
 03548                ClientName = clientName,
 03549                ColorIndex = priceIndexes["color_index"]?.ToString()
 03550            };
 3551
 3552            // Парсим внешний индекс
 03553            var externalIndex = priceIndexes["external_index_data"];
 03554            if (externalIndex != null && externalIndex.HasValues)
 3555            {
 03556                indexes.ExternalIndex = new PriceIndexData
 03557                {
 03558                    MinPrice = externalIndex["min_price"]?.Value<decimal>() ?? 0,
 03559                    MinPriceCurrency = externalIndex["min_price_currency"]?.ToString() ?? "RUB",
 03560                    PriceIndexValue = externalIndex["price_index_value"]?.Value<double>() ?? 0
 03561                };
 3562            }
 3563
 3564            // Парсим Ozon индекс
 03565            var ozonIndex = priceIndexes["ozon_index_data"];
 03566            if (ozonIndex != null && ozonIndex.HasValues)
 3567            {
 03568                indexes.OzonIndex = new PriceIndexData
 03569                {
 03570                    MinPrice = ozonIndex["min_price"]?.Value<decimal>() ?? 0,
 03571                    MinPriceCurrency = ozonIndex["min_price_currency"]?.ToString() ?? "RUB",
 03572                    PriceIndexValue = ozonIndex["price_index_value"]?.Value<double>() ?? 0
 03573                };
 3574            }
 3575
 3576            // Парсим индекс собственных маркетплейсов
 03577            var selfMarketplacesIndex = priceIndexes["self_marketplaces_index_data"];
 03578            if (selfMarketplacesIndex != null && selfMarketplacesIndex.HasValues)
 3579            {
 03580                indexes.SelfMarketplacesIndex = new PriceIndexData
 03581                {
 03582                    MinPrice = selfMarketplacesIndex["min_price"]?.Value<decimal>() ?? 0,
 03583                    MinPriceCurrency = selfMarketplacesIndex["min_price_currency"]?.ToString() ?? "RUB",
 03584                    PriceIndexValue = selfMarketplacesIndex["price_index_value"]?.Value<double>() ?? 0
 03585                };
 3586            }
 3587
 03588            return indexes;
 3589        }
 03590        catch (Exception)
 3591        {
 03592            return null;
 3593        }
 03594    }
 3595
 3596    [HttpGet("GetOzonPrices")]
 3597    public async Task<IActionResult> GetOzonPrices(string article, List<string> storeNames)
 3598    {
 03599        article = article?.Trim() ?? string.Empty;
 03600        if (string.IsNullOrWhiteSpace(article))
 03601            return BadRequest(new { message = "Не указан артикул" });
 3602
 03603        storeNames ??= new List<string>();
 3604
 03605        await SetClients();
 3606
 03607        var allItems = new JArray();
 3608
 03609        foreach (var client in _ozonClients)
 3610        {
 03611            if (!TryConfigureOzonApiClient(client))
 3612                continue;
 3613
 03614            var clientId = client.DecryptClientId!;
 03615            JObject result = await _ozonApiDataManager.GetProductPricesByArticles(new[] { article });
 03616            var stocks = await _ozonApiDataManager.GetProductStocks(new[] { article });
 03617            List<WarehouseOzon> warehouseList = await GetOrCreateWarehouseCache(clientId);
 3618
 03619            if (result?["items"] is JArray priceItems && priceItems.HasValues)
 3620            {
 03621                foreach (var item in priceItems)
 3622                {
 03623                    var commissions = item["commissions"];
 03624                    var priceObj = item["price"];
 03625                    var typedItem = item.ToObject<ProductPriceItemResponse>();
 3626
 03627                    PriceDumpingPreview? recommendation = null;
 03628                    string? recommendationError = null;
 03629                    if (typedItem != null)
 3630                    {
 3631                        try
 3632                        {
 03633                            recommendation = await _importProductPricesManager.GetPriceDumpingPreview(typedItem, client,
 03634                        }
 03635                        catch (Exception ex)
 3636                        {
 03637                            recommendationError = FormatRecommendationError(ex);
 03638                            Console.WriteLine(
 03639                                $"[BitrixStock/GetOzonPrices] recommendation failed; client='{client.Name}', offer_id='{
 03640                        }
 3641                    }
 3642
 03643                    if (commissions != null && priceObj != null)
 3644                    {
 03645                        decimal fboDeliv = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>() ?? 0;
 03646                        decimal fbsDirectMin = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>() ?? 0;
 03647                        decimal fbsDirectMax = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0;
 03648                        decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>() ?? 0;
 03649                        decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0;
 03650                        decimal salesPercent = commissions["sales_percent_fbs"]?.Value<decimal>() ?? 0;
 03651                        decimal itemPrice = priceObj["price"]?.Value<decimal>() ?? 0;
 03652                        decimal acquiring = item["acquiring"]?.Value<decimal>() ?? 0;
 03653                        double index = 0.91;
 03654                        double marketingPrice = priceObj["marketing_seller_price"]?.Value<double>() ?? 0;
 3655
 03656                        decimal ozonReward = itemPrice * salesPercent / 100;
 3657
 03658                        decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv;
 03659                        decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv;
 3660
 03661                        double truePrice = recommendation?.CurrentCustomerPrice ?? _importProductPricesManager.GetTruePr
 3662
 03663                        if (priceObj is JObject priceJObject)
 3664                        {
 03665                            priceJObject["max_commission"] = Math.Round(maxCommission, 2);
 03666                            priceJObject["min_commission"] = Math.Round(minCommission, 2);
 03667                            priceJObject["ozon_reward"] = Math.Round(ozonReward, 2);
 03668                            priceJObject["true_price"] = Math.Round(truePrice, 2);
 03669                            if (recommendation != null)
 3670                            {
 03671                                priceJObject["customer_price"] = Math.Round(recommendation.CurrentCustomerPrice, 2);
 03672                                priceJObject["customer_discount_percent"] = recommendation.DiscountPercent;
 03673                                priceJObject["green_target_customer_price"] = Math.Round(recommendation.TargetCustomerPr
 03674                                priceJObject["green_target_seller_price"] = Math.Round(recommendation.TargetSellerPrice,
 03675                                priceJObject["green_target_index"] = Math.Round(recommendation.TargetIndex, 2);
 03676                                priceJObject["green_target_source"] = recommendation.Source;
 03677                                priceJObject["green_target_source_price"] = Math.Round(recommendation.SourceMinPrice, 2)
 03678                                priceJObject["green_target_source_index"] = Math.Round(recommendation.SourceIndexValue, 
 03679                                priceJObject["customer_price_factor"] = Math.Round(recommendation.CustomerPriceFactor, 4
 03680                                priceJObject["customer_price_formula"] = recommendation.CurrentPriceExplanation;
 03681                                priceJObject["green_target_formula"] = recommendation.TargetPriceExplanation;
 03682                                priceJObject["green_target_candidates"] = recommendation.CandidatesExplanation;
 3683                            }
 03684                            else if (!string.IsNullOrWhiteSpace(recommendationError))
 3685                            {
 03686                                priceJObject["green_target_error"] = recommendationError;
 3687                            }
 3688                        }
 3689                    }
 3690
 03691                    var wrapped = new JObject
 03692                    {
 03693                        ["Article"] = article,
 03694                        ["Client"] = client.Name,
 03695                        ["Item"] = JToken.FromObject(item)
 03696                    };
 3697
 3698
 03699                    var bitrixStoreFilter = storeNames
 3700                        .Where(s => !string.IsNullOrWhiteSpace(s))
 3701                        .Select(s => s.Trim())
 03702                        .Distinct(StringComparer.OrdinalIgnoreCase)
 03703                        .ToList();
 3704
 03705                    await EnsureWarehouseMappingsInternalAsync(client, bitrixStoreFilter, warehouseList);
 3706
 3707                    List<string> allowedOzonWarehouseNames;
 03708                    if (bitrixStoreFilter.Count > 0)
 3709                    {
 03710                        allowedOzonWarehouseNames =
 03711                            await _warehouseMappingDataServices.GetByNamByClient(client, bitrixStoreFilter);
 3712                    }
 3713                    else
 3714                    {
 03715                        allowedOzonWarehouseNames =
 03716                            await _warehouseMappingDataServices.GetOzonWarehouseNamesByClient(client.Name);
 3717                    }
 3718
 03719                    var warehouseInfo =
 03720                        await _ozonApiDataManager.GetWarehouseInfo(stocks, article, client, warehouseList);
 3721
 03722                    if (warehouseInfo == null) continue;
 3723
 03724                    var warehousesArray = warehouseInfo["result"] as JArray ?? new JArray();
 03725                    var allowedSet = new HashSet<string>(
 3726                        allowedOzonWarehouseNames.Where(n => !string.IsNullOrWhiteSpace(n)),
 03727                        StringComparer.OrdinalIgnoreCase);
 3728
 03729                    if (allowedSet.Count == 0)
 3730                    {
 03731                        warehousesArray.Clear();
 3732                    }
 3733
 03734                    var itemsToRemove = warehousesArray
 03735                        .Where(w =>
 03736                        {
 03737                            var name = w["warehouse_name"]?.Value<string>();
 03738                            var present = w["present"]?.Value<int>() ?? 0;
 03739                            return string.IsNullOrWhiteSpace(name) ||
 03740                                   !allowedSet.Contains(name) ||
 03741                                   present <= 0;
 03742                        })
 03743                        .ToList();
 3744
 03745                    foreach (var jItem in itemsToRemove)
 3746                    {
 03747                        jItem.Remove();
 3748                    }
 3749
 03750                    if (warehouseInfo != null)
 3751                    {
 03752                        wrapped["WarehouseInfo"] = warehouseInfo;
 3753                    }
 3754
 03755                    allItems.Add(wrapped);
 03756                }
 3757            }
 03758        }
 3759
 03760        return Content(allItems.ToString(), "application/json");
 03761    }
 3762
 3763    [HttpGet("GetSyncReportPriceInfo")]
 3764    public async Task<IActionResult> GetSyncReportPriceInfo(string reportId, CancellationToken cancellationToken)
 3765    {
 03766        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 03767        var info = await _bitrixStockSyncReportService.GetOzonPriceInfoAsync(reportId, cancellationToken);
 03768        return Json(info);
 03769    }
 3770
 3771    [HttpGet("GetSyncReportAggregates")]
 3772    public async Task<IActionResult> GetSyncReportAggregates(string reportId, CancellationToken cancellationToken)
 3773    {
 03774        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 03775        var agg = await _bitrixStockSyncReportService.GetReportAggregatesAsync(reportId.Trim(), cancellationToken);
 03776        return Json(agg);
 03777    }
 3778
 3779    [HttpGet("GetSyncReportPricedArticles")]
 3780    public async Task<IActionResult> GetSyncReportPricedArticles(string reportId, CancellationToken cancellationToken)
 3781    {
 03782        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 03783        var articles = await _bitrixStockSyncReportService.GetOzonPricedArticlesAsync(reportId, cancellationToken);
 03784        return Json(articles);
 03785    }
 3786
 3787    [HttpGet("GetSyncReportPromos")]
 3788    public async Task<IActionResult> GetSyncReportPromos(string reportId, CancellationToken cancellationToken)
 3789    {
 03790        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 03791        var promos = await _bitrixStockSyncReportService.GetReportPromosAsync(reportId, cancellationToken);
 03792        return Json(promos);
 03793    }
 3794
 3795    [HttpGet("GetSyncReportPriceSettings")]
 3796    public async Task<IActionResult> GetSyncReportPriceSettings(string reportId, CancellationToken cancellationToken)
 3797    {
 03798        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 03799        var settings = await _bitrixStockSyncReportService.GetReportPriceSettingsAsync(reportId, cancellationToken);
 03800        return Json(settings ?? new Services.BitrixStockServices.ReportPriceSettingsDto());
 03801    }
 3802
 3803    [HttpGet("GetArticleC1Price")]
 3804    public async Task<IActionResult> GetArticleC1Price(string article, CancellationToken cancellationToken)
 3805    {
 03806        if (string.IsNullOrWhiteSpace(article)) return BadRequest();
 03807        var (c0, c1) = await _bitrixStockSyncReportService.GetArticleC1PriceAsync(article.Trim(), cancellationToken);
 03808        return Json(new { c0, c1 });
 03809    }
 3810
 3811    /// <summary>
 3812    /// Рассчитывает оптимальную цену по Модели 2 для артикула из отчёта.
 3813    /// Объединяет снимок цен Ozon, C0/C1 из 1С и настройки % из пресета.
 3814    /// </summary>
 3815    [HttpGet("GetPriceOptimization")]
 3816    public async Task<IActionResult> GetPriceOptimization(string reportId, string article, CancellationToken cancellatio
 3817    {
 03818        if (string.IsNullOrWhiteSpace(reportId) || string.IsNullOrWhiteSpace(article))
 03819            return BadRequest();
 3820
 03821        article = article.Trim();
 3822
 03823        var (pricesTask, c1Task, settingsTask, promosTask) = (
 03824            _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellationToken),
 03825            _bitrixStockSyncReportService.GetArticleC1PriceAsync(article, cancellationToken),
 03826            _bitrixStockSyncReportService.GetReportPriceSettingsAsync(reportId, cancellationToken),
 03827            _bitrixStockSyncReportService.GetReportPromosAsync(reportId, cancellationToken)
 03828        );
 3829
 03830        await Task.WhenAll(pricesTask, c1Task, settingsTask, promosTask);
 3831
 03832        var snapshots   = pricesTask.Result;
 03833        var (c0, c1)    = c1Task.Result;
 03834        var settings    = settingsTask.Result;
 03835        var promos      = promosTask.Result;
 3836
 03837        if (snapshots.Count == 0)
 03838            return Json(new { error = "Нет снимка цен Ozon для этого артикула." });
 3839
 3840        // promo — берём первую запись по артикулу (clientId не важен для флага)
 03841        var promoEntry = promos.FirstOrDefault(p =>
 03842            string.Equals(p.Article, article, StringComparison.OrdinalIgnoreCase));
 03843        bool inPromo     = promoEntry?.InPromo ?? false;
 03844        string? promoName = promoEntry?.PromoName;
 3845
 03846        decimal noPromoMarkup = settings?.NoPromoMarkupPercent ?? 5m;
 03847        decimal promoMarkup   = settings?.PromoMarkupPercent   ?? 10m;
 3848
 3849        // Считаем по каждому клиенту (снимку), возвращаем массив результатов
 03850        var results = new JArray();
 03851        foreach (var snap in snapshots)
 3852        {
 3853            JObject priceJson;
 03854            try { priceJson = JObject.Parse(snap.PriceDataJson); }
 03855            catch { continue; }
 3856
 03857            var optResult = PriceOptimizationManager.Calculate(new PriceOptimizationInput
 03858            {
 03859                C0                   = c0,
 03860                C1                   = c1,
 03861                PriceJson            = priceJson,
 03862                InPromo              = inPromo,
 03863                NoPromoMarkupPercent = noPromoMarkup,
 03864                PromoMarkupPercent   = promoMarkup
 03865            });
 3866
 3867            // Поля из снимка — нужны для SetProductPrice без повторного запроса к Ozon
 03868            long?   snapProductId = priceJson["product_id"]?.Value<long?>();
 03869            string? snapOfferId   = priceJson["offer_id"]?.Value<string>() ?? snap.OfferId;
 03870            string? snapVat       = priceJson["price"]?["vat"]?.Value<string>();
 03871            int     batchMult     = BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(snapOfferId);
 3872
 03873            results.Add(new JObject
 03874            {
 03875                ["client"]             = snap.ClientName,
 03876                ["article"]            = snap.Article,
 03877                ["productId"]          = snapProductId,
 03878                ["offerId"]            = snapOfferId,
 03879                ["vat"]                = snapVat,
 03880                ["batchMultiplier"]    = batchMult,
 03881                ["inPromo"]            = inPromo,
 03882                ["promoName"]          = promoName,
 03883                ["c0"]                 = c0,
 03884                ["c1"]                 = c1,
 03885                ["error"]              = optResult.Error,
 03886                ["rate"]               = optResult.HasData ? optResult.Rate          : (decimal?)null,
 03887                ["salesPctFbs"]        = optResult.HasData ? optResult.SalesPctFbs   : (decimal?)null,
 03888                ["acquiring"]          = optResult.HasData ? optResult.Acquiring      : (decimal?)null,
 03889                ["cFixedMax"]          = optResult.HasData ? optResult.CFixedMax      : (decimal?)null,
 03890                ["firstMileMax"]       = optResult.HasData ? optResult.FirstMileMax   : (decimal?)null,
 03891                ["directFlowMax"]      = optResult.HasData ? optResult.DirectFlowMax  : (decimal?)null,
 03892                ["lastMile"]           = optResult.HasData ? optResult.LastMile        : (decimal?)null,
 03893                ["pMin"]               = optResult.PMin,
 03894                ["p0"]                 = optResult.P0,
 03895                ["p1"]                 = optResult.P1,
 03896                ["p0Discounted"]       = optResult.P0Discounted,
 03897                ["p1Discounted"]       = optResult.P1Discounted,
 03898                ["winnerPrice"]        = optResult.WinnerPrice,
 03899                ["winnerSource"]       = optResult.WinnerSource,
 03900                ["markupPercentUsed"]  = optResult.MarkupPercentUsed,
 03901                ["noPromoMarkupPercent"] = noPromoMarkup,
 03902                ["promoMarkupPercent"] = promoMarkup
 03903            });
 3904        }
 3905
 03906        return Content(results.ToString(), "application/json");
 03907    }
 3908
 3909    [HttpGet("GetSyncReportOzonPrices")]
 3910    public async Task<IActionResult> GetSyncReportOzonPrices(string reportId, string? article, CancellationToken cancell
 3911    {
 03912        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest();
 3913
 03914        var rows = await _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellationToken);
 3915
 03916        var result = new JArray();
 03917        foreach (var row in rows)
 3918        {
 3919            try
 3920            {
 03921                var item = JObject.Parse(row.PriceDataJson);
 03922                result.Add(new JObject { ["Article"] = row.Article, ["Client"] = row.ClientName, ["Item"] = item });
 03923            }
 03924            catch { /* пропустить повреждённые записи */ }
 3925        }
 03926        return Content(result.ToString(), "application/json");
 03927    }
 3928
 3929    /// <summary>
 3930    /// Получает живые цены из Ozon, сохраняет снимок в БД и возвращает данные для модалки.
 3931    /// Формат ответа идентичен GetSyncReportOzonPrices.
 3932    /// </summary>
 3933    [HttpGet("GetLiveOzonPricesForModal")]
 3934    public async Task<IActionResult> GetLiveOzonPricesForModal(
 3935        string clientName, string article,
 3936        string? reportId, int ozonClientId,
 3937        CancellationToken cancellationToken)
 3938    {
 03939        if (string.IsNullOrWhiteSpace(clientName) || string.IsNullOrWhiteSpace(article))
 03940            return BadRequest();
 3941
 03942        await SetClients();
 03943        var client = _ozonClients.FirstOrDefault(o =>
 03944            string.Equals(o.Name, clientName, StringComparison.OrdinalIgnoreCase));
 03945        if (client == null)
 03946            return Json(Array.Empty<object>());
 3947
 03948        if (!TryGetOzonCredentials(client, out var apiClientId, out var apiKey))
 03949            return Json(Array.Empty<object>());
 3950
 03951        var mgr = CreateOzonApiDataManager(apiClientId, apiKey);
 3952
 3953        // Используем offer_id из снимка (например, "01019483B=CRT"), а не голый артикул — иначе Ozon не найдёт товар
 03954        string[] searchIds = new[] { article };
 03955        if (!string.IsNullOrWhiteSpace(reportId) && ozonClientId > 0)
 3956        {
 03957            var snapshots = await _bitrixStockSyncReportService.GetOzonPriceSnapshotsAsync(reportId, article, cancellati
 03958            var snap = snapshots.FirstOrDefault(s => s.OzonClientId == ozonClientId);
 03959            if (snap != null && !string.IsNullOrWhiteSpace(snap.OfferId))
 03960                searchIds = new[] { snap.OfferId };
 3961        }
 3962
 3963        // Используем raw-метод: возвращает полный Ozon JSON (snake_case) включая customer_price, true_price и др.
 3964        // GetProductPricesByArticlesModel → typed model → JObject.FromObject потерял бы customer_price и enriched поля
 03965        var rawResponse = await mgr.GetProductPricesByArticles(searchIds);
 3966
 03967        var items = rawResponse?["items"] as JArray;
 03968        if (items == null || items.Count == 0)
 03969            return Json(Array.Empty<object>());
 3970
 03971        var itemJson = items[0] as JObject;
 03972        if (itemJson == null)
 03973            return Json(Array.Empty<object>());
 3974
 3975        // Добавляем производные поля (max_commission, true_price) как при сохранении снимка
 03976        EnrichOzonPriceItemInline(itemJson);
 3977
 03978        var freshJson = itemJson.ToString(Newtonsoft.Json.Formatting.None);
 3979
 3980        // Сохраняем свежие данные в БД, чтобы после перезагрузки страницы отчёт показывал актуальную цену
 03981        if (!string.IsNullOrWhiteSpace(reportId) && ozonClientId > 0)
 3982        {
 03983            await _bitrixStockSyncReportService.UpdateOzonPriceSnapshotAsync(
 03984                reportId, article, ozonClientId, freshJson, cancellationToken);
 3985        }
 3986
 03987        var result = new JArray
 03988        {
 03989            new JObject { ["Article"] = article, ["Client"] = clientName, ["Item"] = itemJson }
 03990        };
 03991        return Content(result.ToString(), "application/json");
 03992    }
 3993
 3994
 3995    private static void EnrichOzonPriceItemInline(JObject item)
 3996    {
 03997        var commissions = item["commissions"];
 03998        var priceObj    = item["price"] as JObject;
 03999        if (commissions == null || priceObj == null) return;
 4000
 04001        decimal salesPercent    = commissions["sales_percent_fbs"]?.Value<decimal>()                    ?? 0;
 04002        decimal itemPrice       = priceObj["price"]?.Value<decimal>()                                   ?? 0;
 04003        decimal acquiring       = item["acquiring"]?.Value<decimal>()                                   ?? 0;
 04004        decimal fbsFirstMileMin = commissions["fbs_first_mile_min_amount"]?.Value<decimal>()            ?? 0;
 04005        decimal fbsFirstMileMax = commissions["fbs_first_mile_max_amount"]?.Value<decimal>()            ?? 0;
 04006        decimal fbsDirectMin    = commissions["fbs_direct_flow_trans_min_amount"]?.Value<decimal>()     ?? 0;
 04007        decimal fbsDirectMax    = commissions["fbs_direct_flow_trans_max_amount"]?.Value<decimal>()     ?? 0;
 04008        decimal fboDeliv        = commissions["fbo_deliv_to_customer_amount"]?.Value<decimal>()         ?? 0;
 4009
 04010        decimal ozonReward    = itemPrice * salesPercent / 100;
 04011        decimal maxCommission = ozonReward + acquiring + fbsFirstMileMax + fbsDirectMax + fboDeliv;
 04012        decimal minCommission = ozonReward + acquiring + fbsFirstMileMin + fbsDirectMin + fboDeliv;
 04013        double  marketingPrice = priceObj["marketing_seller_price"]?.Value<double>() ?? 0;
 04014        double  truePrice      = marketingPrice * 0.91;
 4015
 04016        priceObj["max_commission"] = Math.Round(maxCommission, 2);
 04017        priceObj["min_commission"] = Math.Round(minCommission, 2);
 04018        priceObj["ozon_reward"]    = Math.Round(ozonReward, 2);
 04019        priceObj["true_price"]     = Math.Round(truePrice, 2);
 04020    }
 4021
 4022    private Task<List<WarehouseOzon>> GetOrCreateWarehouseCache(string clientId) =>
 04023        GetOrCreateWarehouseCache(clientId, _ozonApiDataManager);
 4024
 4025    private async Task<List<WarehouseOzon>> GetOrCreateWarehouseCache(string clientId, OzonApiDataManager ozonApiDataMan
 4026    {
 04027        var cacheKey = $"WarehouseList_{clientId}";
 04028        if (_memoryCache.TryGetValue(cacheKey, out List<WarehouseOzon>? cachedWarehouses) && cachedWarehouses != null)
 4029        {
 04030            return cachedWarehouses;
 4031        }
 4032
 04033        var warehouseList = await ozonApiDataManager.GetWarehouseList();
 4034
 04035        var cacheOptions = new MemoryCacheEntryOptions()
 04036            .SetAbsoluteExpiration(TimeSpan.FromHours(1))
 04037            .SetSlidingExpiration(TimeSpan.FromMinutes(30));
 4038
 04039        _memoryCache.Set(cacheKey, warehouseList, cacheOptions);
 4040
 04041        return warehouseList;
 04042    }
 4043
 4044    [HttpPost("UpdateProductStocks")]
 4045    public async Task<IActionResult> UpdateProductStocks(
 4046        [FromBody] UpdateProductStocksViewModel updateProductStocksViewModel)
 4047    {
 04048        if (updateProductStocksViewModel == null)
 04049            return BadRequest(new { Message = "Пустой запрос на обновление остатков" });
 4050
 04051        if (string.IsNullOrWhiteSpace(updateProductStocksViewModel.OzonClient))
 04052            return BadRequest(new { Message = "Не указан клиент Ozon" });
 4053
 04054        if (string.IsNullOrWhiteSpace(updateProductStocksViewModel.OfferId))
 04055            return BadRequest(new { Message = "Не указан offer_id товара" });
 4056
 04057        if (updateProductStocksViewModel.WarehouseId <= 0)
 04058            return BadRequest(new { Message = "Некорректный warehouse_id" });
 4059
 04060        if (updateProductStocksViewModel.Stock < 0)
 04061            return BadRequest(new { Message = "Остаток не может быть отрицательным" });
 4062
 04063        if (string.Equals(updateProductStocksViewModel.BitrixActive, "N", StringComparison.OrdinalIgnoreCase))
 4064        {
 04065            return BadRequest(new
 04066            {
 04067                Message = "Товар неактивен (Активность = Нет). Накат остатков в Ozon для него запрещён — количество на с
 04068            });
 4069        }
 4070
 04071        await SetClients();
 04072        _memoryCache.Remove($"WarehouseList_{updateProductStocksViewModel.OzonClient}");
 4073
 04074        var ozonClient = _ozonClients.Where(o => o.Name == updateProductStocksViewModel.OzonClient).FirstOrDefault();
 04075        if (ozonClient == null)
 04076            return NotFound("Клиент не найден");
 4077
 4078        try
 4079        {
 04080            if (!TryConfigureOzonApiClient(ozonClient))
 04081                return NotFound("Клиент не найден или не настроен");
 4082
 04083            var productId = updateProductStocksViewModel.ProductId;
 04084            if (productId <= 0)
 4085            {
 04086                var articleBase = updateProductStocksViewModel.OfferId.Contains('=')
 04087                    ? updateProductStocksViewModel.OfferId.Split('=')[0]
 04088                    : updateProductStocksViewModel.OfferId;
 04089                productId = await _ozonApiDataManager.TryResolveProductIdByOfferIdAsync(
 04090                    updateProductStocksViewModel.OfferId,
 04091                    articleBase);
 4092            }
 4093
 04094            if (productId <= 0)
 4095            {
 04096                return BadRequest(new
 04097                {
 04098                    Message =
 04099                        $"Не найден product_id в Ozon для offer_id «{updateProductStocksViewModel.OfferId}». " +
 04100                        "Проверьте, что товар заведён у этого клиента Ozon."
 04101                });
 4102            }
 4103
 04104            var result = await _ozonApiDataManager.UpdateProductStocks(new List<StockItemForUpdate>
 04105            {
 04106                new StockItemForUpdate
 04107                {
 04108                    OfferId = updateProductStocksViewModel.OfferId,
 04109                    ProductId = productId,
 04110                    Stock = updateProductStocksViewModel.Stock,
 04111                    WarehouseId = updateProductStocksViewModel.WarehouseId
 04112                }
 04113            });
 04114            if (result?.Result == null || result.Result.Count == 0)
 4115            {
 04116                return BadRequest("Ozon API не вернул результат.");
 4117            }
 4118
 4119            var failedUpdates = result.Result.Where(r => r.Errors != null && r.Errors.Any()).ToList();
 04120            if (failedUpdates.Any())
 4121            {
 04122                return BadRequest(new
 04123                {
 04124                    Message = "Обновление остатков выполнено с ошибками",
 4125                    Errors = failedUpdates.SelectMany(f => f.Errors!)
 04126                });
 4127            }
 4128
 04129            if (result.Result.First().Updated)
 4130            {
 04131                return Ok(new
 04132                {
 04133                    Message = "Остатки успешно обновлены",
 04134                    ProductId = productId,
 04135                    Data = result.Result
 04136                });
 4137            }
 4138            else
 4139            {
 04140                return Ok(new
 04141                {
 04142                    Message = "Не удалось обработать остатки",
 04143                    ProductId = productId,
 04144                    Data = result.Result
 04145                });
 4146            }
 4147        }
 04148        catch (Exception ex)
 4149        {
 04150            return StatusCode(500, new { Message = "Ошибка при обработке запроса", Details = ex.Message });
 4151        }
 04152    }
 4153
 4154    [HttpPost("UpdateProductPrice")]
 4155    public async Task<IActionResult> UpdateProductPrice([FromBody] UpdateProductPriceRequest request)
 4156    {
 04157        if (request == null)
 04158            return BadRequest(new { message = "Пустой запрос" });
 04159        if (string.IsNullOrWhiteSpace(request.Article))
 04160            return BadRequest(new { message = "Не указан артикул" });
 04161        if (string.IsNullOrWhiteSpace(request.ClientName))
 04162            return BadRequest(new { message = "Не указан клиент Ozon" });
 04163        if (request.ConstantIndex <= 0)
 04164            return BadRequest(new { message = "ConstantIndex должен быть > 0" });
 04165        if (request.Percent <= 0 || request.Percent >= 100)
 04166            return BadRequest(new { message = "Percent должен быть в диапазоне (0, 100)" });
 4167
 4168        try
 4169        {
 04170            await SetClients();
 04171            var targetClient = _ozonClients.FirstOrDefault(o => o.Name == request.ClientName);
 04172            if (targetClient == null)
 04173                return NotFound(new { message = "Клиент не найден" });
 4174
 04175            var initialIndex = await GetPriceIndexValue(request.Article, targetClient);
 4176
 04177            var result = await _importProductPricesManager.UpdateProductPrices(
 04178                request.Article,
 04179                targetClient,
 04180                request.ConstantIndex,
 04181                request.Percent);
 4182
 04183            result ??= new ProductImportPricesResponse();
 04184            result.Result ??= new List<ProductImportPriceResult>();
 4185
 04186            var maxAttempts = 10;
 04187            var delayMs = 2000;
 04188            var indexChanged = await WaitForIndexChange(
 04189                request.Article,
 04190                targetClient,
 04191                initialIndex,
 04192                maxAttempts,
 04193                delayMs
 04194            );
 4195
 04196            if (result.Result.Count == 0)
 4197            {
 04198                result.Result.Add(new ProductImportPriceResult
 04199                {
 04200                    OfferId = request.Article,
 04201                    Updated = false,
 04202                    Errors = new List<ProductImportPriceError>
 04203                    {
 04204                        new ProductImportPriceError
 04205                        {
 04206                            Code = "EMPTY_OZON_RESPONSE",
 04207                            Message = "Ozon API вернул пустой результат при импорте цен"
 04208                        }
 04209                    }
 04210                });
 4211            }
 4212
 04213            result.Result[0].Message = indexChanged;
 04214            return Ok(result);
 4215        }
 04216        catch (Exception ex)
 4217        {
 04218            var errorText = ex.InnerException == null
 04219                ? ex.Message
 04220                : $"{ex.Message} | Inner: {ex.InnerException.Message}";
 4221
 04222            return StatusCode(500, new
 04223            {
 04224                message = "Ошибка при обновлении цены через Ozon API",
 04225                article = request.Article,
 04226                client = request.ClientName,
 04227                details = errorText
 04228            });
 4229        }
 04230    }
 4231
 4232    [HttpPost("SetProductPrice")]
 4233    public async Task<IActionResult> SetProductPrice([FromBody] SetProductPriceRequest request)
 4234    {
 04235        if (request == null)
 04236            return BadRequest(new { message = "Пустой запрос" });
 04237        if (string.IsNullOrWhiteSpace(request.Article))
 04238            return BadRequest(new { message = "Не указан артикул" });
 04239        if (string.IsNullOrWhiteSpace(request.ClientName))
 04240            return BadRequest(new { message = "Не указан клиент Ozon" });
 4241
 4242        try
 4243        {
 04244            await SetClients();
 04245            var targetClient = _ozonClients.FirstOrDefault(o => o.Name == request.ClientName);
 04246            if (targetClient == null)
 04247                return NotFound(new { message = "Клиент не найден" });
 4248
 04249            var initialIndex = await GetPriceIndexValue(request.Article, targetClient);
 4250
 4251            ProductImportPricesResponse result;
 04252            if (request.ProductId.HasValue && !string.IsNullOrWhiteSpace(request.OfferId))
 4253            {
 4254                // Быстрый путь: данные уже есть из снимка — не запрашиваем Ozon повторно
 04255                result = await _importProductPricesManager.SetProductPricesDirect(
 04256                    request.OfferId,
 04257                    request.ProductId.Value,
 04258                    request.Vat ?? "0",
 04259                    targetClient,
 04260                    request.YourPrice,
 04261                    request.OldPrice,
 04262                    request.MinPrice,
 04263                    request.CostPrice);
 4264            }
 4265            else
 4266            {
 04267                result = await _importProductPricesManager.SetProductPrices(
 04268                    request.Article,
 04269                    targetClient,
 04270                    request.YourPrice,
 04271                    request.OldPrice,
 04272                    request.MinPrice,
 04273                    request.CostPrice);
 4274            }
 4275
 04276            result ??= new ProductImportPricesResponse();
 04277            result.Result ??= new List<ProductImportPriceResult>();
 4278
 4279            // 3️⃣ Настройки ожидания
 04280            var maxAttempts = 10;
 04281            var delayMs = 2000;
 04282            var indexChanged = await WaitForIndexChange(
 04283                request.Article,
 04284                targetClient,
 04285                initialIndex,
 04286                maxAttempts,
 04287                delayMs
 04288            );
 04289            if (result.Result.Count == 0)
 4290            {
 04291                result.Result.Add(new ProductImportPriceResult
 04292                {
 04293                    OfferId = request.Article,
 04294                    Updated = false,
 04295                    Errors = new List<ProductImportPriceError>
 04296                    {
 04297                        new ProductImportPriceError
 04298                        {
 04299                            Code = "EMPTY_OZON_RESPONSE",
 04300                            Message = "Ozon API вернул пустой результат при установке цены"
 04301                        }
 04302                    }
 04303                });
 4304            }
 4305
 04306            result.Result[0].Message = indexChanged;
 4307
 04308            var pushErrors = result.Result[0].Errors ?? new List<ProductImportPriceError>();
 04309            if (pushErrors.Count == 0
 04310                && !string.IsNullOrWhiteSpace(request.ReportId)
 04311                && request.OzonClientId > 0
 04312                && request.PreviousPrice.HasValue)
 4313            {
 04314                _ = _bitrixStockSyncReportService.RecordPricePushAsync(
 04315                    request.ReportId,
 04316                    request.Article,
 04317                    request.OzonClientId,
 04318                    (decimal)request.PreviousPrice.Value,
 04319                    (decimal)request.YourPrice,
 04320                    request.MarkupPct,
 04321                    request.ColorIndexBefore,
 04322                    request.CompetitorPrice,
 04323                    CancellationToken.None);
 4324            }
 4325
 04326            return Ok(result);
 4327        }
 04328        catch (Exception ex)
 4329        {
 04330            var errorText = ex.InnerException == null
 04331                ? ex.Message
 04332                : $"{ex.Message} | Inner: {ex.InnerException.Message}";
 4333
 04334            return StatusCode(500, new
 04335            {
 04336                message = "Ошибка при установке цены через Ozon API",
 04337                article = request.Article,
 04338                client = request.ClientName,
 04339                details = errorText
 04340            });
 4341        }
 04342    }
 4343
 4344    [HttpPost("EnsureWarehouseMappings")]
 4345    public async Task<IActionResult> EnsureWarehouseMappings([FromBody] EnsureWarehouseMappingsRequest request)
 4346    {
 04347        if (request == null || string.IsNullOrWhiteSpace(request.ClientName))
 04348            return BadRequest(new { message = "Не указан клиент Ozon" });
 4349
 04350        await SetClients();
 04351        var client = _ozonClients.FirstOrDefault(c =>
 04352            string.Equals(c.Name, request.ClientName.Trim(), StringComparison.OrdinalIgnoreCase));
 04353        if (client == null)
 04354            return NotFound(new { message = "Клиент не найден" });
 4355
 04356        if (!TryConfigureOzonApiClient(client))
 04357            return BadRequest(new { Message = "Клиент Ozon не настроен" });
 4358
 04359        var warehouseList = await GetOrCreateWarehouseCache(client.DecryptClientId!);
 04360        var bitrixNames = request.BitrixWarehouseNames?
 4361            .Where(s => !string.IsNullOrWhiteSpace(s))
 4362            .Select(s => s.Trim())
 04363            .Distinct(StringComparer.OrdinalIgnoreCase)
 04364            .ToList() ?? new List<string>();
 4365
 04366        var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync(
 04367            _warehouseMappingDataServices, client, bitrixNames, warehouseList);
 04368        return Ok(new
 04369        {
 04370            created = ensureResult.CreatedCount,
 04371            updatedIds = ensureResult.UpdatedIdCount,
 04372            log = ensureResult.LogLines
 04373        });
 04374    }
 4375
 4376    [HttpPost("ToggleActiveStatus")]
 4377    public async Task<IActionResult> ToggleActiveStatus([FromBody] int productId)
 4378    {
 4379        try
 4380        {
 04381            var result = await _repository.ToggleElementActiveStatus(productId);
 4382
 04383            if (result == null)
 4384            {
 04385                return NotFound(new { success = false, message = "Элемент не найден" });
 4386            }
 4387
 04388            return Ok(new
 04389            {
 04390                success = true,
 04391                message = "Статус успешно обновлен",
 04392                isActive = result.Value
 04393            });
 4394        }
 04395        catch (Exception ex)
 4396        {
 04397            return StatusCode(500, new
 04398            {
 04399                success = false,
 04400                message = "Ошибка при обновлении статуса",
 04401                error = ex.Message
 04402            });
 4403        }
 04404    }
 4405
 4406
 4407    private async Task<List<object>> EnsureWarehouseMappingsInternalAsync(
 4408        OzonClient client,
 4409        List<string> bitrixWarehouseNames,
 4410        List<WarehouseOzon> warehouseList)
 4411    {
 04412        var ensureResult = await BitrixWarehouseMappingEnsure.EnsureAsync(
 04413            _warehouseMappingDataServices,
 04414            client,
 04415            bitrixWarehouseNames,
 04416            warehouseList);
 04417        return ensureResult.Created.ToList();
 04418    }
 4419
 4420    private async Task SetClients()
 4421    {
 04422        if (_ozonClients.Count == 0)
 4423        {
 4424            _ozonClients = (await _ozonClientServices.GetOzonClients()).Where(o => o.ClientType == ClientType.OZON)
 04425                .ToList();
 04426            _ = PreloadWarehouseCachesAsync();
 4427        }
 4428
 04429        var defaultClient = _ozonClients.FirstOrDefault(HasOzonCredentials);
 04430        if (defaultClient == null || !TryGetOzonCredentials(defaultClient, out var defaultClientId, out var defaultApiKe
 04431            throw new InvalidOperationException("Нет Ozon-клиентов с настроенными учётными данными.");
 4432
 04433        _ozonApiDataManager = CreateOzonApiDataManager(defaultClientId, defaultApiKey);
 04434    }
 4435
 4436    private async Task<decimal?> GetPriceIndexValue(string article, OzonClient targetClient)
 4437    {
 4438        try
 4439        {
 04440            if (!TryConfigureOzonApiClient(targetClient))
 04441                return null;
 4442
 04443            JObject prices = await _ozonApiDataManager.GetProductPricesByArticles(new[] { article });
 4444
 04445            var item = prices["items"]?.FirstOrDefault();
 04446            var priceIndexValue = item?["price_indexes"]?["ozon_index_data"]?["price_index_value"]?.Value<decimal>();
 4447
 04448            return priceIndexValue;
 4449        }
 04450        catch (Exception ex)
 4451        {
 04452            await NotificationService.NotifyAllAsync($"Ошибка при получении индекса цены: {ex.Message}");
 04453            return null;
 4454        }
 04455    }
 4456
 4457    private async Task<string> WaitForIndexChange(
 4458        string article,
 4459        OzonClient targetClient,
 4460        decimal? initialIndex,
 4461        int maxAttempts,
 4462        int delayMs)
 4463    {
 04464        var attemptsLog = new List<string>();
 04465        var startTime = DateTime.Now; // фиксируем время начала
 4466
 04467        for (int attempt = 1; attempt <= maxAttempts; attempt++)
 4468        {
 04469            await Task.Delay(delayMs);
 4470
 04471            var currentIndex = await GetPriceIndexValue(article, targetClient);
 4472
 4473            // Логируем состояние каждой попытки
 04474            attemptsLog.Add(
 04475                $"{DateTime.Now:dd.MM.yyyy HH:mm:ss} — Попытка {attempt}/{maxAttempts}: индекс = {currentIndex?.ToString
 4476
 04477            if (currentIndex.HasValue && initialIndex.HasValue && currentIndex.Value != initialIndex.Value)
 04478                return await SendSummaryAndReturn(true);
 4479
 04480            if (currentIndex.HasValue != initialIndex.HasValue)
 04481                return await SendSummaryAndReturn(true);
 4482        }
 4483
 04484        return await SendSummaryAndReturn(false);
 4485
 4486        // Локальная функция для отправки итогового отчёта
 4487        async Task<string> SendSummaryAndReturn(bool changed)
 4488        {
 04489            var status = changed ? "✅ Индекс изменился" : "❌ Индекс не изменился";
 04490            var textReport = status + "\n"; // добавляем переносы после статуса
 4491
 04492            foreach (var attempt in attemptsLog)
 4493            {
 04494                textReport += attempt + "\n";
 4495            }
 4496
 4497            // Вычисляем общее время в секундах
 04498            var totalSeconds = (DateTime.Now - startTime).TotalSeconds;
 04499            textReport += $"\nОбщее затраченное время: {totalSeconds:0.##} секунд";
 4500
 04501            await NotificationService.NotifyAllAsync(textReport);
 04502            return $"{status} ({totalSeconds:0.##}) с.";
 04503        }
 04504    }
 4505
 4506    private async Task PreloadWarehouseCachesAsync()
 4507    {
 4508        try
 4509        {
 04510            foreach (var client in _ozonClients.Where(HasOzonCredentials))
 4511            {
 04512                _ = Task.Run(async () =>
 04513                {
 04514                    try
 04515                    {
 04516                        var cacheKey = $"WarehouseList_{client.DecryptClientId}";
 04517
 04518                        if (_memoryCache.TryGetValue(cacheKey, out _))
 04519                        {
 04520                            Console.WriteLine($"Склады уже в кэше для {client.Name}, пропускаем...");
 04521                            return;
 04522                        }
 04523
 04524                        if (!TryConfigureOzonApiClient(client))
 04525                            return;
 04526
 04527                        var tempManager = _ozonApiDataManager;
 04528                        var warehouses = await tempManager.GetWarehouseList();
 04529
 04530                        var cacheOptions = new MemoryCacheEntryOptions()
 04531                            .SetAbsoluteExpiration(TimeSpan.FromHours(1));
 04532
 04533                        _memoryCache.Set(cacheKey, warehouses, cacheOptions);
 04534                        Console.WriteLine($"Склады добавлены в кэш для {client.Name}");
 04535                    }
 04536                    catch (Exception ex)
 04537                    {
 04538                        Console.WriteLine($"Background cache preload failed for client {client.Name}: {ex.Message}");
 04539                    }
 04540                });
 4541
 04542                await Task.Delay(100);
 4543            }
 04544        }
 04545        catch (Exception ex)
 4546        {
 04547            Console.WriteLine($"Cache preloading failed: {ex.Message}");
 04548        }
 04549    }
 4550
 4551    private static bool HasPointedProductSearch(RemainingStockFilter filter) =>
 04552        filter.ProductId.HasValue || !string.IsNullOrWhiteSpace(filter.Article);
 4553
 4554    private static bool HasNarrowingSearch(RemainingStockFilter filter) =>
 04555        HasPointedProductSearch(filter) ||
 04556        !string.IsNullOrWhiteSpace(filter.Supplier) ||
 04557        !string.IsNullOrWhiteSpace(filter.PreviewText) ||
 04558        filter.OnlyInactive ||
 04559        filter.OnlyActive ||
 04560        !string.IsNullOrWhiteSpace(filter.Active) ||
 04561        !string.IsNullOrWhiteSpace(filter.Available) ||
 04562        filter.QuantityFrom.HasValue ||
 04563        filter.QuantityTo.HasValue ||
 04564        filter.OzonAmountFrom.HasValue ||
 04565        filter.OzonAmountTo.HasValue ||
 04566        !string.IsNullOrWhiteSpace(filter.OzonStoreTitle) ||
 04567        filter.DiffOzonWarehouses;
 4568
 4569    private static bool IsWideOzonBrowsing(RemainingStockFilter filter) =>
 04570        filter.LoadOzonWarehouses && !HasNarrowingSearch(filter);
 4571
 4572    /// <summary>Авто-push в Ozon при загрузке отключён — только ручные кнопки наката/обнуления.</summary>
 04573    private static bool ShouldSyncLinkedStocksToOzonOnLoad(RemainingStockFilter filter, int itemCount) => false;
 4574
 4575    private static bool IsOzonLoadFailure(Exception ex)
 4576    {
 04577        for (var current = ex; current != null; current = current.InnerException)
 4578        {
 04579            if (current is TaskCanceledException or TimeoutException or HttpRequestException)
 4580            {
 04581                return true;
 4582            }
 4583
 04584            if (current.GetType().FullName == "Polly.CircuitBreaker.BrokenCircuitException")
 4585            {
 04586                return true;
 4587            }
 4588        }
 4589
 04590        return false;
 4591    }
 4592
 4593    private static string DescribeOzonLoadFailure(Exception ex)
 4594    {
 04595        for (var current = ex; current != null; current = current.InnerException)
 4596        {
 04597            if (current.GetType().FullName == "Polly.CircuitBreaker.BrokenCircuitException")
 4598            {
 04599                return "сервис Ozon временно недоступен (circuit breaker), повторите через минуту";
 4600            }
 4601
 04602            if (current is TaskCanceledException or TimeoutException)
 4603            {
 04604                return "превышено время ожидания API";
 4605            }
 4606
 04607            if (current is HttpRequestException httpEx)
 4608            {
 04609                return string.IsNullOrWhiteSpace(httpEx.Message)
 04610                    ? "ошибка соединения с API Ozon"
 04611                    : httpEx.Message;
 4612            }
 4613        }
 4614
 04615        return ex.Message;
 4616    }
 4617
 4618    private static string BuildArticlesCacheKeyPart(string[] articles)
 4619    {
 04620        if (articles.Length == 0)
 4621        {
 04622            return "empty";
 4623        }
 4624
 04625        if (articles.Length <= 50)
 4626        {
 04627            return string.Join("|", articles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase));
 4628        }
 4629
 04630        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(
 04631            string.Join('\n', articles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))));
 04632        return Convert.ToHexString(bytes);
 4633    }
 4634
 4635    private bool IsBareBitrixStockPageLoad()
 4636    {
 04637        return !Request.Query.Keys.Any(key =>
 04638            !key.Equals("page", StringComparison.OrdinalIgnoreCase) &&
 04639            !key.Equals("pagesize", StringComparison.OrdinalIgnoreCase));
 4640    }
 4641
 4642    private void AppendPerformanceNotice(string message)
 4643    {
 04644        if (string.IsNullOrWhiteSpace(message))
 4645        {
 04646            return;
 4647        }
 4648
 04649        ViewData["PerformanceNotice"] = ViewData["PerformanceNotice"] is string existing && !string.IsNullOrWhiteSpace(e
 04650            ? $"{existing} {message}"
 04651            : message;
 04652    }
 4653
 4654    // ══════════════════════════════════════════════════════════════
 4655    // Справочник исключений
 4656    // ══════════════════════════════════════════════════════════════
 4657
 4658    [ServiceEndpoint]
 4659    [HttpGet("exceptions/articles")]
 4660    [HttpGet("/exceptions/articles")]
 4661    public async Task<IActionResult> GetExceptionArticles(CancellationToken ct)
 4662    {
 04663        var items = await _exceptionService.GetExceptionArticlesAsync(ct);
 04664        return Ok(items);
 04665    }
 4666
 4667    [ServiceEndpoint]
 4668    [HttpPost("exceptions/articles")]
 4669    [HttpPost("/exceptions/articles")]
 4670    [IgnoreAntiforgeryToken]
 4671    public async Task<IActionResult> UpsertExceptionArticle([FromBody] BitrixStockExceptionArticle entity, CancellationT
 4672    {
 04673        entity.Id = 0;
 04674        entity.CreatedBy = User.Identity?.Name;
 04675        var result = await _exceptionService.UpsertExceptionArticleAsync(entity, ct);
 04676        return Ok(result);
 04677    }
 4678
 4679    [ServiceEndpoint]
 4680    [HttpPut("exceptions/articles/{id:int}")]
 4681    [HttpPut("/exceptions/articles/{id:int}")]
 4682    [IgnoreAntiforgeryToken]
 4683    public async Task<IActionResult> UpdateExceptionArticle(int id, [FromBody] BitrixStockExceptionArticle entity, Cance
 4684    {
 04685        entity.Id = id;
 04686        entity.CreatedBy = User.Identity?.Name;
 04687        var result = await _exceptionService.UpsertExceptionArticleAsync(entity, ct);
 04688        return Ok(result);
 04689    }
 4690
 4691    [ServiceEndpoint]
 4692    [HttpDelete("exceptions/articles/{id:int}")]
 4693    [HttpDelete("/exceptions/articles/{id:int}")]
 4694    [IgnoreAntiforgeryToken]
 4695    public async Task<IActionResult> DeleteExceptionArticle(int id, CancellationToken ct)
 4696    {
 04697        await _exceptionService.DeleteExceptionArticleAsync(id, ct);
 04698        return Ok(new { deleted = true });
 04699    }
 4700
 4701    [ServiceEndpoint]
 4702    [HttpGet("exceptions/client-prices")]
 4703    [HttpGet("/exceptions/client-prices")]
 4704    public async Task<IActionResult> GetClientPrices(CancellationToken ct)
 4705    {
 04706        var items = await _exceptionService.GetClientPricesAsync(ct);
 04707        return Ok(items);
 04708    }
 4709
 4710    [ServiceEndpoint]
 4711    [HttpPost("exceptions/client-prices")]
 4712    [HttpPost("/exceptions/client-prices")]
 4713    [IgnoreAntiforgeryToken]
 4714    public async Task<IActionResult> UpsertClientPrice([FromBody] BitrixStockExceptionClientPrice entity, CancellationTo
 4715    {
 04716        entity.Id = 0;
 04717        entity.CreatedBy = User.Identity?.Name;
 04718        var result = await _exceptionService.UpsertClientPriceAsync(entity, ct);
 04719        return Ok(result);
 04720    }
 4721
 4722    [ServiceEndpoint]
 4723    [HttpPut("exceptions/client-prices/{id:int}")]
 4724    [HttpPut("/exceptions/client-prices/{id:int}")]
 4725    [IgnoreAntiforgeryToken]
 4726    public async Task<IActionResult> UpdateClientPrice(int id, [FromBody] BitrixStockExceptionClientPrice entity, Cancel
 4727    {
 04728        entity.Id = id;
 04729        entity.CreatedBy = User.Identity?.Name;
 04730        var result = await _exceptionService.UpsertClientPriceAsync(entity, ct);
 04731        return Ok(result);
 04732    }
 4733
 4734    [ServiceEndpoint]
 4735    [HttpDelete("exceptions/client-prices/{id:int}")]
 4736    [HttpDelete("/exceptions/client-prices/{id:int}")]
 4737    [IgnoreAntiforgeryToken]
 4738    public async Task<IActionResult> DeleteClientPrice(int id, CancellationToken ct)
 4739    {
 04740        await _exceptionService.DeleteClientPriceAsync(id, ct);
 04741        return Ok(new { deleted = true });
 04742    }
 4743
 4744    [ServiceEndpoint]
 4745    [HttpGet("exceptions/stock-rules")]
 4746    [HttpGet("/exceptions/stock-rules")]
 4747    public async Task<IActionResult> GetStockRules(CancellationToken ct)
 4748    {
 04749        var items = await _exceptionService.GetStockRulesAsync(ct);
 04750        return Ok(items);
 04751    }
 4752
 4753    [ServiceEndpoint]
 4754    [HttpPost("exceptions/stock-rules")]
 4755    [HttpPost("/exceptions/stock-rules")]
 4756    [IgnoreAntiforgeryToken]
 4757    public async Task<IActionResult> UpsertStockRule([FromBody] BitrixStockExceptionStockRule entity, CancellationToken 
 4758    {
 04759        entity.Id = 0;
 04760        entity.CreatedBy = User.Identity?.Name;
 04761        var result = await _exceptionService.UpsertStockRuleAsync(entity, ct);
 04762        return Ok(result);
 04763    }
 4764
 4765    [ServiceEndpoint]
 4766    [HttpPut("exceptions/stock-rules/{id:int}")]
 4767    [HttpPut("/exceptions/stock-rules/{id:int}")]
 4768    [IgnoreAntiforgeryToken]
 4769    public async Task<IActionResult> UpdateStockRule(int id, [FromBody] BitrixStockExceptionStockRule entity, Cancellati
 4770    {
 04771        entity.Id = id;
 04772        entity.CreatedBy = User.Identity?.Name;
 04773        var result = await _exceptionService.UpsertStockRuleAsync(entity, ct);
 04774        return Ok(result);
 04775    }
 4776
 4777    [ServiceEndpoint]
 4778    [HttpDelete("exceptions/stock-rules/{id:int}")]
 4779    [HttpDelete("/exceptions/stock-rules/{id:int}")]
 4780    [IgnoreAntiforgeryToken]
 4781    public async Task<IActionResult> DeleteStockRule(int id, CancellationToken ct)
 4782    {
 04783        await _exceptionService.DeleteStockRuleAsync(id, ct);
 04784        return Ok(new { deleted = true });
 04785    }
 4786
 4787    private static string FormatRecommendationError(Exception ex)
 4788    {
 04789        var rawMessage = ex.Message ?? string.Empty;
 4790
 04791        if (rawMessage.Contains("no access to this method", StringComparison.OrdinalIgnoreCase))
 04792            return "Для этого кабинета Ozon недоступен расчет детализированной цены. Рекомендация не рассчитана.";
 4793
 04794        if (rawMessage.Contains("Forbidden", StringComparison.OrdinalIgnoreCase))
 04795            return "Ozon отклонил запрос на расчет рекомендации для этого кабинета.";
 4796
 04797        if (rawMessage.Contains("timed out", StringComparison.OrdinalIgnoreCase) ||
 04798            rawMessage.Contains("timeout", StringComparison.OrdinalIgnoreCase))
 04799            return "Ozon не ответил вовремя. Рекомендация не рассчитана.";
 4800
 04801        return "Не удалось рассчитать рекомендацию цены для этого кабинета Ozon.";
 4802    }
 4803
 4804    private static bool HasOzonCredentials(OzonClient client) =>
 04805        !string.IsNullOrWhiteSpace(client.DecryptClientId) && !string.IsNullOrWhiteSpace(client.DecryptApiKey);
 4806
 4807    private static bool TryGetOzonCredentials(OzonClient client, out string clientId, out string apiKey)
 4808    {
 04809        clientId = client.DecryptClientId ?? string.Empty;
 04810        apiKey = client.DecryptApiKey ?? string.Empty;
 04811        return !string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(apiKey);
 4812    }
 4813
 4814    private OzonApiDataManager CreateOzonApiDataManager(string clientId, string apiKey) =>
 04815        new(_httpClientFactory, _emailService, clientId, apiKey);
 4816
 4817    private bool TryConfigureOzonApiClient(OzonClient client)
 4818    {
 04819        if (!TryGetOzonCredentials(client, out var clientId, out var apiKey))
 04820            return false;
 4821
 04822        _ozonApiDataManager.SetClient(clientId, apiKey);
 04823        return true;
 4824    }
 4825
 4826}

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/BitrixStockController.StocksGrid.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Mvc;
 2using OzonDomains.Models.Authorization.Enums;
 3using OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix;
 4using OzonOrdersWeb.Authorization;
 5using OzonOrdersWeb.WebServices.BitrixStock;
 6using Services.BitrixStockServices;
 7
 8/// <summary>
 9/// Вкладка «Остатки» на AG Grid: страница строк тем же конвейером, что и раньше рендерил Razor
 10/// (WarehouseStock snapshot → ApplyFiltersAsync с Ozon API), строки собирает <see cref="BitrixStockGridRowBuilder"/>.
 11/// Фильтры и сортировка — параметры <see cref="RemainingStockFilter"/> в query string (как у старой формы и ссылок).
 12/// </summary>
 13public partial class BitrixStockController
 14{
 15    [HttpGet("stocks-grid")]
 16    [PageAuthorize(BitrixStockPageCode, AccessType.View)]
 17    public async Task<IActionResult> GetStocksGrid([FromQuery] RemainingStockFilter filter)
 18    {
 019        if (filter.Page <= 0) filter.Page = 1;
 020        if (filter.PageSize <= 0) filter.PageSize = 100;
 021        _ozonLinkageSyncReport = null;
 22
 023        var vm = await BuildStocksViewModelAsync(filter, loadItems: true);
 024        var rows = BitrixStockGridRowBuilder.Build(
 025            vm.Result.Items,
 026            vm.WarehousesmMappings,
 027            vm.Filter.SelectedStoreTitles,
 028            vm.Filter.SelectedClientId,
 029            vm.SelectedClient?.Name);
 30
 031        var linkage = ViewData["OzonLinkageSyncReport"] as BitrixOzonStockSyncSummary;
 032        return Ok(new
 033        {
 034            success = true,
 035            rows,
 036            totalCount = vm.Result.TotalCount,
 037            page = vm.Result.Page,
 038            pageSize = vm.Result.PageSize,
 039            totalPages = vm.Result.PageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(vm.Result.TotalCount / (double)vm.R
 040            notice = ViewData["PerformanceNotice"] as string,
 041            linkage = linkage is { Attempted: > 0 }
 042                ? new
 043                {
 044                    linkage.Attempted,
 45                    succeeded = linkage.Entries.Count(e => e.Success),
 46                    failed = linkage.Entries.Count(e => !e.Success && !BitrixOzonStockLinkage.IsMissingOzonProductError(
 047                }
 048                : null,
 049            filter = new
 050            {
 051                vm.Filter.SelectedStoreTitles,
 052                vm.Filter.LoadOzonWarehouses,
 053                vm.Filter.LoadOzonPriceIndexes,
 054                vm.Filter.SelectedClientId
 055            }
 056        });
 057    }
 58}

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/BitrixStockController.SyncGrid.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Mvc;
 2using MySqlConnector;
 3using OzonDomains.Models.Authorization.Enums;
 4using OzonOrdersWeb.Authorization;
 5using OzonOrdersWeb.WebServices.BitrixStock;
 6using Services.BitrixStockServices;
 7
 8/// <summary>
 9/// Вкладка «Синхронизация» на AG Grid: история запусков (Client-Side Row Model), строки отчёта
 10/// (Infinite Row Model с серверной сортировкой/фильтрами) и счётчики значений; настройки пользователя — GridSettingsCon
 11/// POST-чтения помечены <see cref="PageAuthorizeAttribute"/> с View — иначе auto-авторизация потребовала бы Edit.
 12/// </summary>
 13public partial class BitrixStockController
 14{
 15    private const string BitrixStockPageCode = "Studio2.BitrixStock.Index";
 16    private const int SyncRunsGridMaxRows = 2000;
 17
 18    /// <summary>История запусков целиком (до 2000) — сортировка, фильтры и поиск в гриде на клиенте.</summary>
 19    [HttpGet("sync-reports/grid-runs")]
 20    [PageAuthorize(BitrixStockPageCode, AccessType.View)]
 21    public async Task<IActionResult> GetSyncRunsGrid(CancellationToken cancellationToken)
 22    {
 23        try
 24        {
 025            var result = await _bitrixStockSyncReportService.ListReportsAsync(
 026                new BitrixStockSyncReportRunsQuery { Page = 1, PageSize = SyncRunsGridMaxRows, MaxPageSize = SyncRunsGri
 027                cancellationToken);
 028            foreach (var item in result.Items)
 029                item.ActiveStage = _activeStageStore.Get(item.ReportId ?? string.Empty);
 030            return Ok(new { success = true, rows = result.Items, totalCount = result.TotalCount });
 31        }
 032        catch (MySqlException ex) when (ex.Message.Contains("Timeout", StringComparison.OrdinalIgnoreCase))
 33        {
 034            return StatusCode(StatusCodes.Status503ServiceUnavailable,
 035                new { success = false, error = "База данных временно занята. Повторите загрузку через несколько секунд."
 36        }
 037    }
 38
 39    /// <summary>Блок строк отчёта для Infinite Row Model.</summary>
 40    [HttpPost("sync-reports/{reportId}/grid-rows")]
 41    [ValidateAntiForgeryToken]
 42    [PageAuthorize(BitrixStockPageCode, AccessType.View)]
 43    public async Task<IActionResult> GetSyncReportGridRows(
 44        string reportId,
 45        [FromBody] BitrixSyncGridRequest? request,
 46        [FromServices] IBitrixStockSyncReportGridService gridService,
 47        CancellationToken cancellationToken)
 48    {
 049        if (string.IsNullOrWhiteSpace(reportId)) return BadRequest(new { success = false, error = "reportId обязателен" 
 050        var error = BitrixSyncGridRequestMapper.Validate(request);
 051        if (error != null) return BadRequest(new { success = false, error });
 52
 053        var result = await gridService.GetLinesAsync(reportId, BitrixSyncGridRequestMapper.Map(request!), cancellationTo
 054        var lastRow = request!.StartRow + result.Rows.Count >= result.TotalCount ? result.TotalCount : -1;
 055        return Ok(new { success = true, rows = result.Rows, totalCount = result.TotalCount, lastRow });
 056    }
 57
 58    /// <summary>Значения колонки с количеством строк при остальных фильтрах — для справочных фильтров грида.</summary>
 59    [HttpPost("sync-reports/{reportId}/grid-facets")]
 60    [ValidateAntiForgeryToken]
 61    [PageAuthorize(BitrixStockPageCode, AccessType.View)]
 62    public async Task<IActionResult> GetSyncReportGridFacets(
 63        string reportId,
 64        [FromBody] BitrixSyncGridRequest? request,
 65        [FromServices] IBitrixStockSyncReportGridService gridService,
 66        CancellationToken cancellationToken)
 67    {
 068        if (string.IsNullOrWhiteSpace(reportId) || string.IsNullOrWhiteSpace(request?.ColId))
 069            return BadRequest(new { success = false, error = "reportId и colId обязательны" });
 070        request.StartRow = 0;
 071        request.EndRow = 1;
 072        var error = BitrixSyncGridRequestMapper.Validate(request);
 073        if (error != null) return BadRequest(new { success = false, error });
 74
 075        var values = await gridService.GetFacetsAsync(reportId, request.ColId, BitrixSyncGridRequestMapper.Map(request),
 076        return Ok(new { success = true, values });
 077    }
 78}

Methods/Properties

.ctor(OzonRepositories.Data.Bitrix.BitrixStockRepository,OzonRepositories.Data.EtProducerRepository,Services.DataServices.OzonClientServices,System.Net.Http.IHttpClientFactory,SlqStudio.Application.Services.EmailService.IEmailService,Services.PriceСlculationServices.CurrencyRateFetcher,Services.DataServices.SupplierDataServices,Services.DataServices.IPriceHistoryDataService,Services.DataServices.WarehouseMappingDataServices,Microsoft.Extensions.Caching.Memory.IMemoryCache,Services.ImportProductPricesServices.ImportProductPricesManager,Services.DataServices.BitrixStockSavedFilterDataServices,Services.ApiServices.OneCDataManager,Services.BitrixStockServices.IBitrixStockClearJobService,OzonRepositories.Data.IBitrixStockClearJobRepository,Services.BitrixStockServices.IBitrixStockArchiveJobService,OzonRepositories.Data.IBitrixStockArchiveJobRepository,Services.BitrixStockServices.IBitrixStockUnarchiveJobService,OzonRepositories.Data.IBitrixStockUnarchiveJobRepository,Services.BitrixStockServices.IBitrixStockPushJobService,OzonRepositories.Data.IBitrixStockPushJobRepository,Services.BitrixStockServices.IBitrixStockAutoScheduleService,Services.BitrixStockServices.IBitrixStockInventoryService,Services.BitrixStockServices.IBitrixOzonLinkedStockApiSyncService,Services.BitrixStockServices.IWarehouseStockBitrixViewService,Services.BitrixStockServices.IBitrixStockSyncReportService,Services.BitrixStockServices.IBitrixStockSyncReportApplyService,Services.BitrixStockServices.IBitrixStockSyncReportCleanupService,Services.BitrixStockServices.IBitrixStockSyncReportPricePushService,Services.BitrixStockServices.IBitrixStockSyncPresetService,Services.BitrixStockServices.IBitrixStockOzonOrphanCleanupService,Services.BitrixStockServices.BitrixStockSyncApplyArticleLogService,Services.BitrixStockServices.BitrixStockActiveStageStore,Services.BitrixStockServices.IBitrixStockExceptionService,Services.ChannelTaskServices.IChannelTaskService,Hangfire.IRecurringJobManager)
get_ViewModel()
Index()
BuildStocksViewModelAsync()
GetGoogleSyncAutomationStatus()
SaveGoogleSyncAutomation()
GetGoogleSyncAutomationHistory(System.Int32)
TriggerGoogleSyncAutomationNow()
IsGoogleAutoJob(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.Object>)
BuildHoursCron(System.Int32)
ParseHoursFromCron(System.String)
RetryHangfireLockWaitTimeoutAsync()
IsLockWaitTimeout(System.Exception)
get_IsEnabled()
get_IntervalHours()
get_JobId()
get_Status()
get_StartedAt()
get_FinishedAt()
get_DurationMs()
get_Error()
SaveFilter()
DeleteFilter()
GetFilter()
get_Name()
get_FilterData()
get_ClientName()
get_BitrixWarehouseNames()
get_Articles()
GetOneCStocksByArticles()
FromArray()
ParseOneCStockArticles(System.Text.Json.JsonElement)
GetOzonProductArchiveByOfferIds()
FromArray()
ParseOzonArchiveOfferIds(System.Text.Json.JsonElement)
LoadOzonProductArchiveStatusesAsync()
<LoadOzonProductArchiveStatusesAsync()
StartClearJob()
GetClearJobs()
GetClearJobDetails()
GetWarehouseMappingsForClient()
StartSyncReport(Services.BitrixStockServices.BitrixStockSyncReportStartRequest)
StartSyncReportApply(System.String,Services.BitrixStockServices.BitrixStockSyncReportApplyStartRequest)
StartSyncReportPricePush(System.String,Services.BitrixStockServices.BitrixStockSyncReportPricePushStartRequest)
GetCleanupPreview()
ZeroCleanupWarehouse()
StartCleanup(System.String,Services.BitrixStockServices.BitrixStockSyncCleanupStartRequest)
GetOzonOrphanPreview()
StartOzonOrphanCleanup(System.String,Services.BitrixStockServices.BitrixStockOzonOrphanCleanupRequest)
GetOzonReportFiles()
DownloadOzonReportFile()
GetArchivedPreview()
ListSyncReports()
GetSyncPresets()
CreateSyncPreset()
UpdateSyncPreset()
DeleteSyncPreset()
GetSyncReportOneCStores()
GetSyncReportMappingCombos()
GetSyncReport()
DownloadSyncReportLog()
GetSyncReportProductNames()
DebugOzonArticleLookup()
DebugFbsStockPush()
GetOrderReserveMap()
ExportOrderReserveMapExcel()
BuildReserveMapExcel(Services.BitrixStockServices.OrderReserveMapDto,System.Collections.Generic.List`1<System.String>,System.String)
ExportSyncReportExcel(System.String,System.String,System.Collections.Generic.List`1<System.String>,System.String,System.String,System.Collections.Generic.List`1<System.String>,System.Boolean,System.Boolean,System.Boolean,System.Boolean)
StartSyncReportExcelExportTask(System.String,System.String,System.Collections.Generic.List`1<System.String>,System.String,System.String,System.Collections.Generic.List`1<System.String>,System.Boolean,System.Boolean,System.Boolean,System.Boolean)
GetSyncReportExcelExportTaskStatus(System.String)
DownloadSyncReportExcelExportTaskResult(System.String)
SendSyncReportEmail(System.String,System.String,System.Collections.Generic.List`1<System.String>,System.String,System.String,System.Collections.Generic.List`1<System.String>,System.Boolean,System.Boolean,System.Boolean,System.Boolean)
BuildSyncReportQuery(System.String,System.Collections.Generic.List`1<System.String>,System.String,System.String,System.Collections.Generic.List`1<System.String>,System.Boolean,System.Boolean,System.Boolean,System.Boolean)
GetApplyArticleLog()
GetApplyLogJobs()
GetApplyLogAnalysis()
get_OfferIds()
UnarchiveReportProducts()
ArchiveReportProducts()
BuildSyncReportExcelAsync(System.String,Services.BitrixStockServices.BitrixStockSyncReportQuery)
StartPushJob()
GetPushJobs()
GetPushJobDetails()
StartArchiveJob()
GetArchiveJobs()
GetArchiveJobDetails()
StartUnarchiveJob()
GetUnarchiveJobs()
GetUnarchiveJobDetails()
ParseAutoScheduleFilter()
PreviewAutoSchedule()
GetAutoSchedules()
CreateAutoSchedule()
UpdateAutoSchedule()
DeleteAutoSchedule()
TriggerAutoSchedule()
GetAutoScheduleRuns()
ValidateAutoScheduleRequest(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.BitrixStockAutoScheduleRequest)
GetAutoScheduleDefaultFilter()
BuildAutoScheduleFilterJsonAsync()
MapAutoScheduleEntityAsync()
MapAutoScheduleDto(OzonDomains.Models.BitrixStockAutoSchedule)
DescribeStoredScheduleFilter(OzonDomains.Models.BitrixStockAutoSchedule)
ApplyFiltersAsync()
GetFullExceptionMessage(System.Exception)
LoadOzonWarehousesOptimizedAsync()
GetBitrixStoreNamesForMapping(System.Collections.Generic.List`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>,OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter)
GetAllowedBitrixStoreNames(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter,System.Collections.Generic.List`1<OzonDomains.Models.BitrixModels.RemainingStockBitrix>)
PreloadAllWarehousesAsync()
<PreloadAllWarehousesAsync()
ProcessClientWarehousesAsync()
ProcessItemWarehousesAsync()
LoadOzonPriceIndexesAsync()
<LoadOzonPriceIndexesAsync()
ParsePriceIndexes(Newtonsoft.Json.Linq.JToken,System.String)
GetOzonPrices()
GetSyncReportPriceInfo()
GetSyncReportAggregates()
GetSyncReportPricedArticles()
GetSyncReportPromos()
GetSyncReportPriceSettings()
GetArticleC1Price()
GetPriceOptimization()
GetSyncReportOzonPrices()
GetLiveOzonPricesForModal()
EnrichOzonPriceItemInline(Newtonsoft.Json.Linq.JObject)
GetOrCreateWarehouseCache(System.String)
GetOrCreateWarehouseCache()
UpdateProductStocks()
UpdateProductPrice()
SetProductPrice()
EnsureWarehouseMappings()
ToggleActiveStatus()
EnsureWarehouseMappingsInternalAsync()
SetClients()
GetPriceIndexValue()
WaitForIndexChange()
SendSummaryAndReturn()
PreloadWarehouseCachesAsync()
<PreloadWarehouseCachesAsync()
HasPointedProductSearch(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter)
HasNarrowingSearch(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter)
IsWideOzonBrowsing(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter)
ShouldSyncLinkedStocksToOzonOnLoad(OzonOrdersWeb.Areas.Studio2.ViewModels.Bitrix.RemainingStockFilter,System.Int32)
IsOzonLoadFailure(System.Exception)
DescribeOzonLoadFailure(System.Exception)
BuildArticlesCacheKeyPart(System.String[])
IsBareBitrixStockPageLoad()
AppendPerformanceNotice(System.String)
GetExceptionArticles()
UpsertExceptionArticle()
UpdateExceptionArticle()
DeleteExceptionArticle()
GetClientPrices()
UpsertClientPrice()
UpdateClientPrice()
DeleteClientPrice()
GetStockRules()
UpsertStockRule()
UpdateStockRule()
DeleteStockRule()
FormatRecommendationError(System.Exception)
HasOzonCredentials(OzonDomains.Models.OzonClient)
TryGetOzonCredentials(OzonDomains.Models.OzonClient,System.String&,System.String&)
CreateOzonApiDataManager(System.String,System.String)
TryConfigureOzonApiClient(OzonDomains.Models.OzonClient)
GetStocksGrid()
GetSyncRunsGrid()
GetSyncReportGridRows()
GetSyncReportGridFacets()