< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.WarehouseStockController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/WarehouseStockController.cs
Line coverage
10%
Covered lines: 36
Uncovered lines: 308
Coverable lines: 344
Total lines: 619
Line coverage: 10.4%
Branch coverage
5%
Covered branches: 10
Total branches: 176
Branch coverage: 5.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Index()0%2040%
GridRows()0%4260%
GridFacets()0%4260%
get_WarehouseStockGridDefinition()100%210%
Lookup()0%4260%
MovementMap(...)0%4260%
MovementApi()0%210140%
UpdateReserveLine()0%156120%
get_Article()100%210%
get_DocumentRefKey()100%210%
get_LineNumber()100%210%
get_Quantity()100%210%
get_Reason()100%210%
AdjustStock()0%110100%
get_Article()100%210%
get_Warehouse()100%210%
get_Quantity()100%210%
get_Reason()100%210%
OneCLookup()0%7280%
OneCStockCheck()0%4260%
get_Lines()100%210%
get_OrderId()100%210%
get_Article()100%210%
get_ProductName()100%210%
get_Producer()100%210%
get_Warehouse()100%210%
get_Quantity()100%210%
Api()0%2040%
SyncGoogleGet(...)100%11100%
SyncGoogleStart(...)100%11100%
SyncGoogleStartGet(...)100%11100%
EnqueueGoogleSheetSyncIfAuthorized(...)83.33%66100%
ResolveStartSyncCode()62.5%8885.71%
SyncGoogle()0%4260%
SyncExcel()0%7280%
BuildFilteredQuery(...)0%420200%
ApplySorting(...)0%1980440%

File(s)

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

#LineLine coverage
 1using Microsoft.AspNetCore.Authorization;
 2using Microsoft.AspNetCore.Mvc;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Configuration;
 5using Microsoft.Extensions.Options;
 6using OzonOrdersWeb.Areas.Studio2.ViewModels.WarehouseStock;
 7using OzonDomains.Models.Authorization.Enums;
 8using OzonOrdersWeb.Authorization;
 9using OzonOrdersWeb.WebServices.Grid;
 10using OzonOrdersWeb.WebServices.Journals;
 11using OzonRepositories.Context;
 12using Hangfire;
 13using Services.ApiServices;
 14using Services.ApiServices._1CApi.Stock;
 15using Services.Authorization.Services;
 16using Services.StockSyncServices;
 17
 18namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 19
 20[Authorize(Roles = "User,Admin")]
 21[Area("Studio2")]
 22[Route("Studio2/WarehouseStock")]
 23[Route("WarehouseStock")]
 24public class WarehouseStockController : Controller
 25{
 26    private readonly ApplicationDbContext _context;
 27    private readonly IWarehouseStockSyncService _syncService;
 28    private readonly IWarehouseStockLookupService _warehouseStockLookup;
 29    private readonly IBackgroundJobClient _backgroundJobs;
 30    private readonly GoogleSheetsStockSyncOptions _stockSyncOptions;
 31    private readonly IConfiguration _configuration;
 32    private readonly OneCDataManager _oneCDataManager;
 33    private readonly IOneCStockAvailabilityService _stockAvailability;
 34    private readonly IAuditService _audit;
 35
 936    public WarehouseStockController(
 937        ApplicationDbContext context,
 938        IWarehouseStockSyncService syncService,
 939        IWarehouseStockLookupService warehouseStockLookup,
 940        IBackgroundJobClient backgroundJobs,
 941        IOptions<GoogleSheetsStockSyncOptions> stockSyncOptions,
 942        IConfiguration configuration,
 943        OneCDataManager oneCDataManager,
 944        IOneCStockAvailabilityService stockAvailability,
 945        IAuditService audit)
 46    {
 947        _stockAvailability = stockAvailability;
 948        _context = context;
 949        _syncService = syncService;
 950        _warehouseStockLookup = warehouseStockLookup;
 951        _backgroundJobs = backgroundJobs;
 952        _stockSyncOptions = stockSyncOptions.Value;
 953        _configuration = configuration;
 954        _oneCDataManager = oneCDataManager;
 955        _audit = audit;
 956    }
 57
 58    private const string PageCode = "Studio2.WarehouseStock.Index";
 59
 60    /// <summary>
 61    /// Каркас страницы: строки грида приходят блоками из <see cref="GridRows"/>. Параметры прежних ссылок
 62    /// (?article=, ?manufacturer=, ?warehouse=, ?minQty=) страница превращает в стартовые фильтры грида.
 63    /// </summary>
 64    [HttpGet("")]
 65    public async Task<IActionResult> Index(
 66        string? article = null,
 67        string? manufacturer = null,
 68        string? warehouse = null,
 69        decimal? minQty = null,
 70        CancellationToken cancellationToken = default)
 71    {
 072        var lastSync = await _context.WarehouseStockSnapshots.AsNoTracking()
 073            .GroupBy(_ => 1)
 074            .Select(g => new { Total = g.Count(), SyncedAtUtc = g.Max(x => x.SyncedAtUtc) })
 075            .SingleOrDefaultAsync(cancellationToken);
 76
 077        return View(new WarehouseStockIndexViewModel
 078        {
 079            TotalCount = lastSync?.Total ?? 0,
 080            LastSyncedAtUtc = lastSync?.SyncedAtUtc,
 081            Article = article,
 082            Manufacturer = manufacturer,
 083            Warehouse = warehouse,
 084            MinQty = minQty
 085        });
 086    }
 87
 88    [HttpPost("grid")]
 89    [ValidateAntiForgeryToken]
 90    [PageAuthorize(PageCode, AccessType.View)]
 91    public async Task<IActionResult> GridRows([FromBody] ServerGridRequest? request, CancellationToken cancellationToken
 92    {
 093        var error = ServerGridQuery.Validate(request);
 094        if (error != null) return BadRequest(new { success = false, error });
 95
 096        var def = WarehouseStockGridDefinition;
 097        var filtered = ServerGridQuery.ApplyFilters(_context.WarehouseStockSnapshots.AsNoTracking(), def, request!);
 098        var total = await filtered.CountAsync(cancellationToken);
 099        var start = Math.Max(0, request!.StartRow);
 0100        var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 0101        var rows = start >= total
 0102            ? []
 0103            : await ServerGridQuery.ApplySort(filtered, def, request.SortModel).Skip(start).Take(take)
 0104                .Select(x => new
 0105                {
 0106                    x.Id, x.SourceRowNumber, x.Manufacturer, x.Article, x.ProductName, x.SaleFlag,
 0107                    x.M2Qty, x.M1Qty, x.M3Qty, x.B1Qty, x.B2Qty,
 0108                    x.UnitCost, x.TotalCost, x.SalePriceUnit, x.AccountingPriceUnit, x.PriceDiffAccountingMinusSale,
 0109                    x.C0, x.StockAmountByC0, x.ExistPrice, x.C1ExistMinus5Percent, x.C1PercentToC0,
 0110                    x.Warehouse, x.SyncedAtUtc
 0111                })
 0112                .ToListAsync(cancellationToken);
 0113        var lastRow = start + rows.Count >= total ? total : -1;
 0114        return Ok(new { success = true, rows, totalCount = total, lastRow });
 0115    }
 116
 117    [HttpPost("grid/facets")]
 118    [ValidateAntiForgeryToken]
 119    [PageAuthorize(PageCode, AccessType.View)]
 120    public async Task<IActionResult> GridFacets([FromBody] ServerGridRequest? request, CancellationToken cancellationTok
 121    {
 0122        if (request is null || string.IsNullOrWhiteSpace(request.ColId))
 0123            return BadRequest(new { success = false, error = "colId обязателен" });
 0124        request.StartRow = 0;
 0125        request.EndRow = 1;
 0126        var error = ServerGridQuery.Validate(request);
 0127        if (error != null) return BadRequest(new { success = false, error });
 128
 0129        var values = await ServerGridQuery.FacetsAsync(_context.WarehouseStockSnapshots.AsNoTracking(),
 0130            WarehouseStockGridDefinition, request, cancellationToken);
 0131        return Ok(new { success = true, values });
 0132    }
 133
 134    private static ServerGridDefinition<OzonDomains.Models.WarehouseStockSnapshot> WarehouseStockGridDefinition
 0135        => JournalGridDefinitions.WarehouseStock;
 136
 137    [ServiceEndpoint]
 138    [HttpGet("lookup")]
 139    public async Task<IActionResult> Lookup(
 140        string? article = null,
 141        string? manufacturer = null,
 142        CancellationToken cancellationToken = default)
 143    {
 0144        if (string.IsNullOrWhiteSpace(article))
 0145            return Ok(new { found = false });
 146
 0147        var match = await _warehouseStockLookup.LookupAsync(article, manufacturer, cancellationToken);
 0148        if (match is not { Found: true })
 0149            return Ok(new { found = false });
 150
 0151        return Ok(new
 0152        {
 0153            found = true,
 0154            fuzzyManufacturer = match.FuzzyManufacturer,
 0155            id = match.Id,
 0156            sourceRowNumber = match.SourceRowNumber,
 0157            m2Qty = match.M2Qty,
 0158            m1Qty = match.M1Qty,
 0159            m3Qty = match.M3Qty,
 0160            b1Qty = match.B1Qty,
 0161            b2Qty = match.B2Qty,
 0162            c0 = match.C0
 0163        });
 0164    }
 165
 166    [AllowAnonymous]
 167    [AssemblyListPublicAccess]
 168    [HttpGet("movement-map")]
 169    public IActionResult MovementMap(string? article, string? accessKey)
 170    {
 0171        ViewBag.AccessKey = accessKey ?? string.Empty;
 0172        return View(model: (article ?? string.Empty).Trim());
 173    }
 174
 175    [AllowAnonymous]
 176    [AssemblyListPublicAccess]
 177    [HttpGet("movement-api")]
 178    public async Task<IActionResult> MovementApi(string? article, CancellationToken cancellationToken = default)
 179    {
 0180        if (string.IsNullOrWhiteSpace(article))
 0181            return Ok(new { found = false, error = "article is empty" });
 182
 183        try
 184        {
 0185            var result = await _oneCDataManager.GetMovementMapAsync(article.Trim());
 0186            if (result == null)
 0187                return Ok(new { found = false, error = "Артикул не найден в 1С" });
 188
 0189            return Ok(new
 0190            {
 0191                found = true,
 0192                article = result.Article,
 0193                name = result.NomenclatureName,
 0194                currentBalance = (result.CurrentBalance ?? []).Select(b => new { warehouse = b.Warehouse, quantity = b.Q
 0195                currentReserve = (result.CurrentReserve ?? []).Select(b => new { warehouse = b.Warehouse, quantity = b.Q
 0196                currentReserveLines = User.IsInRole("Admin")
 0197                    ? (result.CurrentReserveLines ?? []).Select(r => new
 0198                    {
 0199                        documentRefKey = r.DocumentRefKey,
 0200                        lineNumber = r.LineNumber,
 0201                        warehouse = r.Warehouse,
 0202                        quantity = r.Quantity
 0203                    })
 0204                    : [],
 0205                summary = new
 0206                {
 0207                    receiptQty = result.TotalReceiptQty,
 0208                    receiptDocs = result.TotalReceiptDocs,
 0209                    expenseQty = result.TotalExpenseQty,
 0210                    expenseDocs = result.TotalExpenseDocs,
 0211                    transferQty = result.TotalTransferQty,
 0212                    transferDocs = result.TotalTransferDocs,
 0213                    calculatedBalance = result.TotalReceiptQty - result.TotalExpenseQty
 0214                },
 0215                movements = (result.Movements ?? []).Select(m => new
 0216                {
 0217                    date = m.Date.ToString("yyyy-MM-dd HH:mm:ss"),
 0218                    kind = m.Kind.ToString().ToLower(),
 0219                    docType = m.DocType,
 0220                    docNumber = m.DocNumber,
 0221                    refKey = m.RefKey,
 0222                    quantity = m.Quantity,
 0223                    price = m.Price,
 0224                    warehouseFrom = m.WarehouseFrom,
 0225                    warehouseTo = m.WarehouseTo,
 0226                    isDeleted = m.IsDeleted,
 0227                    isPosted = m.IsPosted
 0228                })
 0229            });
 230        }
 0231        catch (Exception ex)
 232        {
 0233            return Ok(new { found = false, error = ex.Message });
 234        }
 0235    }
 236
 237    [HttpPost("reserve-line")]
 238    [Authorize(Roles = "Admin")]
 239    [ValidateAntiForgeryToken]
 240    public async Task<IActionResult> UpdateReserveLine(
 241        [FromForm] ReserveLineUpdateRequest request,
 242        CancellationToken cancellationToken = default)
 243    {
 0244        if (string.IsNullOrWhiteSpace(request.Article) || request.DocumentRefKey == Guid.Empty || request.LineNumber <= 
 0245            return BadRequest(new { success = false, error = "Некорректные данные строки резерва." });
 0246        if (request.Quantity < 0)
 0247            return BadRequest(new { success = false, error = "Количество не может быть отрицательным." });
 0248        if (string.IsNullOrWhiteSpace(request.Reason) || request.Reason.Trim().Length < 3)
 0249            return BadRequest(new { success = false, error = "Укажите причину изменения (минимум 3 символа)." });
 250
 251        try
 252        {
 0253            var result = await _oneCDataManager.UpdateReserveLineAsync(
 0254                request.Article, request.DocumentRefKey, request.LineNumber, request.Quantity);
 255
 0256            await _audit.LogAsync(
 0257                "OneCReserveChanged",
 0258                "Document_РезервированиеЗапасов",
 0259                $"{request.DocumentRefKey}:line={request.LineNumber}",
 0260                System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, quantity = result.OldQuantity
 0261                System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, quantity = result.NewQuantity
 0262                request.Reason.Trim(),
 0263                cancellationToken);
 264
 0265            return Ok(new { success = true, oldQuantity = result.OldQuantity, newQuantity = result.NewQuantity });
 266        }
 0267        catch (Exception ex)
 268        {
 0269            return BadRequest(new { success = false, error = ex.Message });
 270        }
 0271    }
 272
 273    public sealed class ReserveLineUpdateRequest
 274    {
 0275        public string Article { get; init; } = string.Empty;
 0276        public Guid DocumentRefKey { get; init; }
 0277        public int LineNumber { get; init; }
 0278        public decimal Quantity { get; init; }
 0279        public string Reason { get; init; } = string.Empty;
 280    }
 281
 282    [HttpPost("stock-adjustment")]
 283    [Authorize(Roles = "Admin")]
 284    [ValidateAntiForgeryToken]
 285    public async Task<IActionResult> AdjustStock(
 286        [FromForm] StockAdjustmentRequest request,
 287        CancellationToken cancellationToken = default)
 288    {
 0289        if (string.IsNullOrWhiteSpace(request.Article) || string.IsNullOrWhiteSpace(request.Warehouse))
 0290            return BadRequest(new { success = false, error = "Не указан товар или склад." });
 0291        if (request.Quantity < 0)
 0292            return BadRequest(new { success = false, error = "Остаток не может быть отрицательным." });
 0293        if (string.IsNullOrWhiteSpace(request.Reason) || request.Reason.Trim().Length < 3)
 0294            return BadRequest(new { success = false, error = "Укажите причину изменения (минимум 3 символа)." });
 295
 296        try
 297        {
 0298            var result = await _oneCDataManager.AdjustStockByInventoryAsync(
 0299                request.Article, request.Warehouse, request.Quantity, request.Reason);
 0300            await _audit.LogAsync(
 0301                "OneCStockAdjusted",
 0302                "Document_ИнвентаризацияЗапасов",
 0303                result.DocumentNumber,
 0304                System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, warehouse = request.Warehouse
 0305                System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, warehouse = request.Warehouse
 0306                request.Reason.Trim(),
 0307                cancellationToken);
 0308            return Ok(new { success = true, documentNumber = result.DocumentNumber });
 309        }
 0310        catch (Exception ex)
 311        {
 0312            return BadRequest(new { success = false, error = ex.Message });
 313        }
 0314    }
 315
 316    public sealed class StockAdjustmentRequest
 317    {
 0318        public string Article { get; init; } = string.Empty;
 0319        public string Warehouse { get; init; } = string.Empty;
 0320        public decimal Quantity { get; init; }
 0321        public string Reason { get; init; } = string.Empty;
 322    }
 323
 324    [ServiceEndpoint]
 325    [HttpGet("onec-lookup")]
 326    public async Task<IActionResult> OneCLookup(string? article, CancellationToken cancellationToken = default)
 327    {
 0328        if (string.IsNullOrWhiteSpace(article))
 0329            return Ok(new { found = false, debug = "article is empty" });
 330
 331        try
 332        {
 0333            var stocks = await _oneCDataManager.GetStockByArticleDirectAsync(article.Trim());
 334
 335            // Учётная цена по всем складам: суммарная стоимость на суммарное количество.
 0336            var totalQty = stocks.Sum(s => s.Quantity);
 0337            var totalCost = stocks.Sum(s => s.Cost ?? 0m);
 0338            decimal? unitCost = totalCost > 0 && totalQty > 0
 0339                ? Math.Round(totalCost / totalQty, 2, MidpointRounding.AwayFromZero)
 0340                : null;
 341
 0342            return Ok(new
 0343            {
 0344                found = stocks.Count > 0,
 0345                stocks = stocks.Select(s => new
 0346                {
 0347                    warehouse = s.Warehouse,
 0348                    quantity = s.Quantity,
 0349                    unitCost = s.UnitCost.HasValue
 0350                        ? Math.Round(s.UnitCost.Value, 2, MidpointRounding.AwayFromZero)
 0351                        : (decimal?)null
 0352                }),
 0353                unitCost,
 0354                debug = stocks.Count == 0 ? "no stock found" : (string?)null
 0355            });
 356        }
 0357        catch (Exception ex)
 358        {
 0359            return Ok(new { found = false, debug = ex.Message });
 360        }
 0361    }
 362
 363    /// <summary>
 364    /// Пакетная проверка остатков 1С для формы транзакции: агрегация по (карточка номенклатуры,
 365    /// склад) возможна только на сервере, поэтому строки присылаются пачкой, а не по одной.
 366    /// </summary>
 367    [ServiceEndpoint]
 368    [HttpPost("onec-stock-check")]
 369    public async Task<IActionResult> OneCStockCheck(
 370        [FromBody] OneCStockCheckRequest? request,
 371        CancellationToken cancellationToken = default)
 372    {
 0373        var lines = request?.Lines ?? [];
 0374        if (lines.Count == 0)
 0375            return Ok(new { ok = true, unavailable = false, lines = Array.Empty<object>() });
 376
 377        try
 378        {
 0379            var result = await _stockAvailability.CheckAsync(
 0380                lines.Select(l => new StockCheckLine(
 0381                    l.OrderId, l.Article, l.ProductName, l.Producer, l.Warehouse, l.Quantity)).ToList(),
 0382                cancellationToken);
 383
 0384            return Ok(new
 0385            {
 0386                ok = !result.HasBlocking,
 0387                unavailable = result.CheckSkipped,
 0388                lines = result.Lines.Select(l => new
 0389                {
 0390                    orderIds = l.OrderIds,
 0391                    article = l.Article,
 0392                    productName = l.ProductName,
 0393                    warehouse = l.WarehouseName,
 0394                    required = l.Required,
 0395                    available = l.Available,
 0396                    status = l.Status.ToString()
 0397                })
 0398            });
 399        }
 0400        catch (Exception ex)
 401        {
 402            // Предпросмотр никогда не должен ронять форму — как и onec-lookup выше.
 0403            return Ok(new { ok = true, unavailable = true, lines = Array.Empty<object>(), debug = ex.Message });
 404        }
 0405    }
 406
 407    public sealed class OneCStockCheckRequest
 408    {
 0409        public List<OneCStockCheckRequestLine> Lines { get; set; } = [];
 410    }
 411
 412    public sealed class OneCStockCheckRequestLine
 413    {
 0414        public int OrderId { get; set; }
 0415        public string? Article { get; set; }
 0416        public string? ProductName { get; set; }
 0417        public string? Producer { get; set; }
 0418        public string? Warehouse { get; set; }
 0419        public decimal Quantity { get; set; }
 420    }
 421
 422    [HttpGet("api")]
 423    public async Task<IActionResult> Api(
 424        string? article = null,
 425        string? manufacturer = null,
 426        string? productName = null,
 427        string? warehouse = null,
 428        decimal? minQty = null,
 429        string sort = "article",
 430        bool desc = false,
 431        int page = 1,
 432        int pageSize = 50,
 433        CancellationToken cancellationToken = default)
 434    {
 0435        page = page < 1 ? 1 : page;
 0436        pageSize = pageSize <= 0 ? 50 : Math.Min(pageSize, 200);
 437
 0438        var query = BuildFilteredQuery(article, manufacturer, productName, warehouse, minQty);
 0439        var totalCount = await query.CountAsync(cancellationToken);
 0440        query = ApplySorting(query, sort, desc);
 441
 0442        var items = await query
 0443            .Skip((page - 1) * pageSize)
 0444            .Take(pageSize)
 0445            .Select(x => new
 0446            {
 0447                x.Id,
 0448                x.Manufacturer,
 0449                x.Article,
 0450                x.ProductName,
 0451                x.SaleFlag,
 0452                x.M2Qty,
 0453                x.M1Qty,
 0454                x.M3Qty,
 0455                x.B1Qty,
 0456                x.B2Qty,
 0457                x.SyncedAtUtc
 0458            })
 0459            .ToListAsync(cancellationToken);
 460
 0461        return Ok(new
 0462        {
 0463            totalCount,
 0464            page,
 0465            pageSize,
 0466            items
 0467        });
 0468    }
 469
 470    [AllowAnonymous]
 471    [HttpGet("sync/google")]
 472    public IActionResult SyncGoogleGet([FromQuery] string? code) =>
 1473        EnqueueGoogleSheetSyncIfAuthorized(code);
 474
 475    [AllowAnonymous]
 476    [HttpPost("sync/google/start")]
 477    [IgnoreAntiforgeryToken]
 478    public IActionResult SyncGoogleStart([FromForm] string? code) =>
 5479        EnqueueGoogleSheetSyncIfAuthorized(code);
 480
 481    [AllowAnonymous]
 482    [HttpGet("sync/google/start")]
 483    public IActionResult SyncGoogleStartGet([FromQuery] string? code) =>
 3484        EnqueueGoogleSheetSyncIfAuthorized(code);
 485
 486    private IActionResult EnqueueGoogleSheetSyncIfAuthorized(string? code)
 487    {
 9488        var expectedCode = ResolveStartSyncCode();
 9489        if (string.IsNullOrWhiteSpace(expectedCode))
 2490            return BadRequest("Не задан GoogleSheetsStockSync:StartSyncCode.");
 491
 7492        if (!string.Equals(expectedCode, (code ?? string.Empty).Trim(), StringComparison.Ordinal))
 2493            return Unauthorized("Неверный код запуска синхронизации.");
 494
 495        // Google Sheets → WarehouseStockSnapshot; при успехе WarehouseStockSyncJob ставит RunAllPresetsJob.
 5496        _backgroundJobs.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync("AnonymousHttpTrigger"));
 5497        return Ok("Синхронизация WarehouseStock поставлена в очередь; при успехе запустятся пресеты BitrixStock.");
 498    }
 499
 500    private string ResolveStartSyncCode()
 501    {
 9502        if (!string.IsNullOrWhiteSpace(_stockSyncOptions.StartSyncCode))
 7503            return _stockSyncOptions.StartSyncCode.Trim();
 504
 2505        var local = _configuration["GoogleSheetsStockSync:Local:StartSyncCode"];
 2506        if (!string.IsNullOrWhiteSpace(local))
 0507            return local.Trim();
 508
 2509        var prod = _configuration["GoogleSheetsStockSync:Prod:StartSyncCode"];
 2510        return prod?.Trim() ?? string.Empty;
 511    }
 512
 513    [Authorize(Roles = "Admin")]
 514    [HttpPost("sync/google")]
 515    [ValidateAntiForgeryToken]
 516    public IActionResult SyncGoogle()
 517    {
 0518        var userName = User?.Identity?.Name ?? "ManualTrigger";
 0519        _backgroundJobs.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync(userName));
 0520        TempData["Success"] = "Синхронизация поставлена в очередь и выполняется в фоне.";
 0521        return RedirectToAction(nameof(Index));
 522    }
 523
 524    [Authorize(Roles = "Admin")]
 525    [HttpPost("sync/excel")]
 526    [ValidateAntiForgeryToken]
 527    public async Task<IActionResult> SyncExcel(IFormFile excelFile, CancellationToken cancellationToken = default)
 528    {
 0529        if (excelFile == null || excelFile.Length == 0)
 530        {
 0531            TempData["Error"] = "Выберите Excel-файл.";
 0532            return RedirectToAction(nameof(Index));
 533        }
 534
 0535        await _syncService.SyncFromExcelAsync(excelFile, User?.Identity?.Name, cancellationToken);
 0536        TempData["Success"] = "Ручной Excel-импорт завершен.";
 0537        return RedirectToAction(nameof(Index));
 0538    }
 539
 540    private IQueryable<OzonDomains.Models.WarehouseStockSnapshot> BuildFilteredQuery(
 541        string? article,
 542        string? manufacturer,
 543        string? productName,
 544        string? warehouse,
 545        decimal? minQty)
 546    {
 0547        var query = _context.WarehouseStockSnapshots.AsNoTracking().AsQueryable();
 548
 0549        if (!string.IsNullOrWhiteSpace(article))
 550        {
 0551            var v = article.Trim();
 0552            query = query.Where(x => x.Article != null && x.Article.Contains(v));
 553        }
 554
 0555        if (!string.IsNullOrWhiteSpace(manufacturer))
 556        {
 0557            var v = manufacturer.Trim();
 0558            query = query.Where(x => x.Manufacturer != null && x.Manufacturer.Contains(v));
 559        }
 560
 0561        if (!string.IsNullOrWhiteSpace(productName))
 562        {
 0563            var v = productName.Trim();
 0564            query = query.Where(x => x.ProductName != null && x.ProductName.Contains(v));
 565        }
 566
 0567        var qtyThreshold = minQty ?? 0m;
 0568        if (!string.IsNullOrWhiteSpace(warehouse))
 569        {
 0570            switch (warehouse.Trim().ToUpperInvariant())
 571            {
 572                case "M2":
 0573                    query = query.Where(x => (x.M2Qty ?? 0m) >= qtyThreshold);
 0574                    break;
 575                case "M1":
 0576                    query = query.Where(x => (x.M1Qty ?? 0m) >= qtyThreshold);
 0577                    break;
 578                case "M3":
 0579                    query = query.Where(x => (x.M3Qty ?? 0m) >= qtyThreshold);
 0580                    break;
 581                case "B1":
 0582                    query = query.Where(x => (x.B1Qty ?? 0m) >= qtyThreshold);
 0583                    break;
 584                case "B2":
 0585                    query = query.Where(x => (x.B2Qty ?? 0m) >= qtyThreshold);
 0586                    break;
 587            }
 588        }
 0589        else if (minQty.HasValue)
 590        {
 0591            query = query.Where(x =>
 0592                (x.M2Qty ?? 0m) >= qtyThreshold ||
 0593                (x.M1Qty ?? 0m) >= qtyThreshold ||
 0594                (x.M3Qty ?? 0m) >= qtyThreshold ||
 0595                (x.B1Qty ?? 0m) >= qtyThreshold ||
 0596                (x.B2Qty ?? 0m) >= qtyThreshold);
 597        }
 598
 0599        return query;
 600    }
 601
 602    private static IQueryable<OzonDomains.Models.WarehouseStockSnapshot> ApplySorting(
 603        IQueryable<OzonDomains.Models.WarehouseStockSnapshot> query,
 604        string sort,
 605        bool desc)
 606    {
 0607        return (sort ?? string.Empty).ToLowerInvariant() switch
 0608        {
 0609            "manufacturer" => desc ? query.OrderByDescending(x => x.Manufacturer).ThenByDescending(x => x.Id) : query.Or
 0610            "updated" => desc ? query.OrderByDescending(x => x.SyncedAtUtc).ThenByDescending(x => x.Id) : query.OrderBy(
 0611            "m2" => desc ? query.OrderByDescending(x => x.M2Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M2Qt
 0612            "m1" => desc ? query.OrderByDescending(x => x.M1Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M1Qt
 0613            "m3" => desc ? query.OrderByDescending(x => x.M3Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M3Qt
 0614            "b1" => desc ? query.OrderByDescending(x => x.B1Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.B1Qt
 0615            "b2" => desc ? query.OrderByDescending(x => x.B2Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.B2Qt
 0616            _ => desc ? query.OrderByDescending(x => x.Article).ThenByDescending(x => x.Id) : query.OrderBy(x => x.Artic
 0617        };
 618    }
 619}