< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.LabelDownloadRequest
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/LabelsController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 1
Coverable lines: 1
Total lines: 423
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_FileNames()100%210%

File(s)

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

#LineLine coverage
 1using iText.Kernel.Pdf;
 2using Microsoft.AspNetCore.Authorization;
 3using Microsoft.AspNetCore.Mvc;
 4using Microsoft.Net.Http.Headers;
 5using OzonOrdersWeb.WebServices.Grid;
 6using Services.LabelServices;
 7
 8namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 9
 10[Authorize(Roles = "User,Admin")]
 11[Area("Studio2")]
 12public sealed class LabelsController : Controller
 13{
 14    private const int MaxSelectedFiles = 100;
 15    private const long MaxMergedBytes = 100L * 1024 * 1024;
 16
 17    private static readonly ServerGridDefinition<LabelCatalogRow> GridDefinition =
 18        new ServerGridDefinition<LabelCatalogRow>
 19        {
 20            DefaultOrder = q => q.OrderByDescending(x => x.PrintedAtUtc).ThenBy(x => x.FileName),
 21            TieBreaker = x => x.Id
 22        }
 23        .Col("fileName", x => x.FileName)
 24        .Col("shipmentNumber", x => x.ShipmentNumber)
 25        .Col("scanit", x => x.Scanit)
 26        .Col("client", x => x.Client)
 27        .Col("marketplace", x => x.Marketplace)
 28        .Col("linked", x => x.HasOrder)
 29        .Col("printedAtUtc", x => x.PrintedAtUtc)
 30        .Col("printedBy", x => x.PrintedBy)
 31        .Col("printSource", x => x.PrintSource)
 32        .Col("modifiedAtUtc", x => x.ModifiedAtUtc)
 33        .Col("sizeBytes", x => x.SizeBytes)
 34        .Col("orderId", x => x.OrderId);
 35
 36    private readonly ILabelFileStorage _labelFileStorage;
 37    private readonly ILabelExistenceService _labelExistenceService;
 38    private readonly ILabelCatalogService _labelCatalog;
 39
 40    public LabelsController(
 41        ILabelFileStorage labelFileStorage,
 42        ILabelExistenceService labelExistenceService,
 43        ILabelCatalogService labelCatalog)
 44    {
 45        _labelFileStorage = labelFileStorage;
 46        _labelExistenceService = labelExistenceService;
 47        _labelCatalog = labelCatalog;
 48    }
 49
 50    [HttpGet]
 51    public IActionResult Index() => View();
 52
 53    [HttpPost]
 54    [ValidateAntiForgeryToken]
 55    public async Task<IActionResult> GridRows([FromBody] ServerGridRequest? request, CancellationToken cancellationToken
 56    {
 57        var error = ServerGridQuery.Validate(request);
 58        if (error != null) return BadRequest(new { success = false, error });
 59
 60        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 61        var filtered = ApplyCatalogQuery(snapshot.Rows, request!);
 62        var total = filtered.Count();
 63        var start = Math.Max(0, request!.StartRow);
 64        var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 65        var rows = start >= total
 66            ? []
 67            : ServerGridQuery.ApplySort(filtered, GridDefinition, request.SortModel)
 68                .Skip(start)
 69                .Take(take)
 70                .Select(ToGridRow)
 71                .ToList();
 72        var lastRow = start + rows.Count >= total ? total : -1;
 73        return Ok(new { success = true, rows, totalCount = total, lastRow, refreshedAtUtc = snapshot.RefreshedAtUtc });
 74    }
 75
 76    [HttpPost]
 77    [ValidateAntiForgeryToken]
 78    public async Task<IActionResult> GridFacets([FromBody] ServerGridRequest? request, CancellationToken cancellationTok
 79    {
 80        if (request is null || string.IsNullOrWhiteSpace(request.ColId))
 81            return BadRequest(new { success = false, error = "colId обязателен." });
 82
 83        request.StartRow = 0;
 84        request.EndRow = 1;
 85        var error = ServerGridQuery.Validate(request);
 86        if (error != null) return BadRequest(new { success = false, error });
 87
 88        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 89        var filtered = ApplyCatalogQuery(snapshot.Rows, request, request.ColId).ToList();
 90        var values = request.ColId switch
 91        {
 92            "client" => StringFacets(filtered, x => x.Client),
 93            "marketplace" => StringFacets(filtered, x => x.Marketplace),
 94            "printedBy" => StringFacets(filtered, x => x.PrintedBy),
 95            "printSource" => StringFacets(filtered, x => x.PrintSource),
 96            "linked" => filtered.GroupBy(x => x.HasOrder ? "true" : "false")
 97                .Select(g => new ServerGridFacet { Value = g.Key, Count = g.Count() })
 98                .OrderByDescending(x => x.Count)
 99                .ToList(),
 100            _ => []
 101        };
 102        return Ok(new { success = true, values });
 103    }
 104
 105    [HttpPost]
 106    [ValidateAntiForgeryToken]
 107    public async Task<IActionResult> RefreshCatalog(CancellationToken cancellationToken)
 108    {
 109        var snapshot = await _labelCatalog.RefreshAsync(cancellationToken);
 110        return Ok(new { success = true, totalCount = snapshot.Rows.Count, refreshedAtUtc = snapshot.RefreshedAtUtc });
 111    }
 112
 113    /// <summary>Отдаёт PDF как attachment только для новой страницы каталога.</summary>
 114    [HttpGet]
 115    public async Task<IActionResult> DownloadFile(string fileName, CancellationToken cancellationToken)
 116    {
 117        var validFile = await FindCatalogFileAsync(fileName, cancellationToken);
 118        if (validFile is null) return NotFound("Этикетка не найдена.");
 119
 120        try
 121        {
 122            var download = await _labelFileStorage.OpenReadAsync(validFile, cancellationToken);
 123            DisableLabelCaching();
 124            return File(download.Content, "application/pdf", validFile, enableRangeProcessing: true);
 125        }
 126        catch (FileNotFoundException)
 127        {
 128            return NotFound("Этикетка больше недоступна. Обновите каталог.");
 129        }
 130    }
 131
 132    /// <summary>Защищённое inline-превью PDF для модального окна каталога.</summary>
 133    [HttpGet]
 134    public async Task<IActionResult> Preview(string fileName, CancellationToken cancellationToken)
 135    {
 136        var validFile = await FindCatalogFileAsync(fileName, cancellationToken);
 137        if (validFile is null) return NotFound("Этикетка не найдена.");
 138
 139        try
 140        {
 141            var download = await _labelFileStorage.OpenReadAsync(validFile, cancellationToken);
 142            DisableLabelCaching();
 143            SetInlineContentDisposition(validFile);
 144            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 145        }
 146        catch (FileNotFoundException)
 147        {
 148            return NotFound("Этикетка больше недоступна. Обновите каталог.");
 149        }
 150    }
 151
 152    [HttpPost]
 153    [ValidateAntiForgeryToken]
 154    public async Task<IActionResult> DownloadSelected(
 155        [FromBody] LabelDownloadRequest? request,
 156        CancellationToken cancellationToken)
 157    {
 158        var requested = request?.FileNames?
 159            .Where(x => !string.IsNullOrWhiteSpace(x))
 160            .Distinct(StringComparer.OrdinalIgnoreCase)
 161            .ToList() ?? [];
 162        if (requested.Count == 0)
 163            return BadRequest(new { success = false, message = "Выберите хотя бы одну этикетку." });
 164        if (requested.Count > MaxSelectedFiles)
 165            return BadRequest(new { success = false, message = $"За один раз можно скачать не более {MaxSelectedFiles} э
 166
 167        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 168        var allowed = snapshot.Rows.Select(x => x.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
 169        if (requested.Any(x => !allowed.Contains(x)))
 170            return BadRequest(new { success = false, message = "Одна из выбранных этикеток больше недоступна." });
 171
 172        var files = new List<(string Name, byte[] Content)>();
 173        long totalBytes = 0;
 174        try
 175        {
 176            foreach (var fileName in requested)
 177            {
 178                var bytes = await _labelFileStorage.TryDownloadAsync(fileName, cancellationToken);
 179                if (bytes is not { Length: > 0 })
 180                    return BadRequest(new { success = false, message = $"Этикетка «{fileName}» отсутствует или пуста." }
 181
 182                totalBytes += bytes.Length;
 183                if (totalBytes > MaxMergedBytes)
 184                    return BadRequest(new { success = false, message = "Размер выбранных этикеток превышает 100 МБ." });
 185                files.Add((fileName, bytes));
 186            }
 187
 188            await using var result = new MemoryStream();
 189            using (var merged = new PdfDocument(new PdfWriter(result)))
 190            {
 191                foreach (var (_, content) in files)
 192                {
 193                    await using var source = new MemoryStream(content);
 194                    using var pdf = new PdfDocument(new PdfReader(source));
 195                    if (pdf.GetNumberOfPages() <= 0)
 196                        return BadRequest(new { success = false, message = "Одна из выбранных этикеток не содержит стран
 197                    pdf.CopyPagesTo(1, pdf.GetNumberOfPages(), merged);
 198                }
 199            }
 200
 201            DisableLabelCaching();
 202            return File(result.ToArray(), "application/pdf", $"labels_{DateTime.UtcNow:yyyyMMdd_HHmmss}.pdf");
 203        }
 204        catch (Exception ex) when (ex is not OperationCanceledException)
 205        {
 206            return BadRequest(new { success = false, message = "Не удалось объединить выбранные PDF: " + ex.Message });
 207        }
 208    }
 209
 210    /// <summary>
 211    /// Старая inline-точка входа для Orders и TransactionsGrid. Не менять на attachment:
 212    /// эти экраны получают PDF fetch-запросом и открывают его в окне печати.
 213    /// </summary>
 214    [HttpGet]
 215    public async Task<IActionResult> Download(string fileName)
 216    {
 217        try
 218        {
 219            var download = await _labelFileStorage.OpenReadAsync(fileName);
 220            DisableLabelCaching();
 221            SetInlineContentDisposition(fileName);
 222            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 223        }
 224        catch (Exception ex)
 225        {
 226            TempData["Error"] = $"Ошибка при скачивании файла: {ex.Message}";
 227            return RedirectToAction(nameof(Index));
 228        }
 229    }
 230
 231    [HttpGet]
 232    public async Task<IActionResult> GetThumbnail(string fileName)
 233    {
 234        try
 235        {
 236            var download = await _labelFileStorage.OpenReadAsync(fileName);
 237            DisableLabelCaching();
 238            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 239        }
 240        catch (Exception ex)
 241        {
 242            return NotFound($"Ошибка при загрузке миниатюры: {ex.Message}");
 243        }
 244    }
 245
 246    [HttpGet]
 247    public async Task<IActionResult> GetPdfForPreview(string fileName)
 248    {
 249        try
 250        {
 251            var download = await _labelFileStorage.OpenReadAsync(fileName);
 252            DisableLabelCaching();
 253            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 254        }
 255        catch (Exception ex)
 256        {
 257            return NotFound($"Ошибка при загрузке PDF: {ex.Message}");
 258        }
 259    }
 260
 261    [HttpGet]
 262    public IActionResult GetDownloadLink(string fileName)
 263    {
 264        var downloadLink = Url.Action(nameof(Download), "Labels", new { fileName }, Request.Scheme);
 265        return Json(new { success = true, downloadLink });
 266    }
 267
 268    [HttpGet]
 269    public IActionResult GetPublicLink(string fileName)
 270    {
 271        var publicLink = Url.Action(nameof(Download), "Labels", new { fileName }, Request.Scheme);
 272        return Json(new { success = true, publicLink });
 273    }
 274
 275    [HttpGet]
 276    public async Task<IActionResult> CheckLabelExists(string shipmentNumber)
 277    {
 278        if (string.IsNullOrWhiteSpace(shipmentNumber))
 279            return Json(new { success = false, message = "ShipmentNumber не указан" });
 280
 281        try
 282        {
 283            var results = await _labelExistenceService.CheckBatchAsync([shipmentNumber]);
 284            return Json(new { success = true, exists = results.TryGetValue(shipmentNumber, out var exists) && exists, fr
 285        }
 286        catch (Exception ex)
 287        {
 288            return Json(new { success = false, message = $"Ошибка при проверке этикетки: {ex.Message}", exists = false }
 289        }
 290    }
 291
 292    [HttpPost]
 293    public async Task<IActionResult> CheckLabelsExist([FromBody] string[] shipmentNumbers)
 294    {
 295        try
 296        {
 297            var results = shipmentNumbers is { Length: > 0 }
 298                ? await _labelExistenceService.CheckBatchAsync(shipmentNumbers)
 299                : new Dictionary<string, bool>();
 300            return Json(new { success = true, results });
 301        }
 302        catch (Exception ex)
 303        {
 304            return Json(new { success = false, message = $"Ошибка при проверке этикеток: {ex.Message}", results = new Di
 305        }
 306    }
 307
 308    public void InvalidateLabelCache(string shipmentNumber) => _labelExistenceService.InvalidateShipment(shipmentNumber)
 309
 310    public void SetLabelCache(string shipmentNumber, bool exists) => _labelExistenceService.SetShipmentExists(shipmentNu
 311
 312    [HttpPost]
 313    [Authorize(Roles = "Admin")]
 314    public async Task<IActionResult> Delete(string fileName)
 315    {
 316        try
 317        {
 318            await _labelFileStorage.DeleteAsync(fileName);
 319            _labelExistenceService.InvalidateShipment(Path.GetFileNameWithoutExtension(fileName));
 320            TempData["Success"] = $"Файл «{fileName}» удалён";
 321        }
 322        catch (Exception ex)
 323        {
 324            TempData["Error"] = $"Ошибка при удалении файла: {ex.Message}";
 325        }
 326
 327        return RedirectToAction(nameof(Index));
 328    }
 329
 330    [HttpPost]
 331    [Authorize(Roles = "Admin")]
 332    public IActionResult MigrateFromDropbox()
 333    {
 334        var jobId = Hangfire.BackgroundJob.Enqueue<DropboxToServerFileStorageMigrationJob>(j => j.ExecuteAsync());
 335        return Json(new { success = true, jobId });
 336    }
 337
 338    private static IQueryable<LabelCatalogRow> ApplyCatalogQuery(
 339        IEnumerable<LabelCatalogRow> rows,
 340        ServerGridRequest request,
 341        string? excludeColId = null)
 342    {
 343        var search = request.Search?.Trim();
 344        var query = string.IsNullOrWhiteSpace(search)
 345            ? rows
 346            : rows.Where(x => MatchesSearch(x, search));
 347        return ServerGridQuery.ApplyFilters(query.AsQueryable(), GridDefinition, request, excludeColId);
 348    }
 349
 350    private static bool MatchesSearch(LabelCatalogRow row, string search) =>
 351        string.Equals(row.Scanit, search, StringComparison.OrdinalIgnoreCase) ||
 352        Contains(row.FileName, search) ||
 353        Contains(row.ShipmentNumber, search) ||
 354        Contains(row.Scanit, search) ||
 355        Contains(row.PrintedBy, search) ||
 356        Contains(row.PrintSource, search) ||
 357        Contains(row.OrderIdText, search);
 358
 359    private static bool Contains(string? value, string search) =>
 360        value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
 361
 362    private static List<ServerGridFacet> StringFacets(
 363        IEnumerable<LabelCatalogRow> rows,
 364        Func<LabelCatalogRow, string?> selector) =>
 365        rows.GroupBy(x => selector(x) ?? string.Empty, StringComparer.OrdinalIgnoreCase)
 366            .Select(g => new ServerGridFacet { Value = g.Key, Count = g.Count() })
 367            .OrderByDescending(x => x.Count)
 368            .ThenBy(x => x.Value, StringComparer.OrdinalIgnoreCase)
 369            .ToList();
 370
 371    private static object ToGridRow(LabelCatalogRow row) => new
 372    {
 373        row.Id,
 374        row.FileName,
 375        row.ShipmentNumber,
 376        row.SizeBytes,
 377        row.ModifiedAtUtc,
 378        row.PrintedAtUtc,
 379        row.UsesFileDate,
 380        row.PrintedBy,
 381        row.PrintSource,
 382        row.OrderId,
 383        row.RelatedOrderIds,
 384        row.Scanit,
 385        row.Client,
 386        row.Marketplace,
 387        row.HasOrder,
 388        row.RelatedOrderCount
 389    };
 390
 391    private async Task<string?> FindCatalogFileAsync(string? fileName, CancellationToken cancellationToken)
 392    {
 393        if (string.IsNullOrWhiteSpace(fileName)) return null;
 394        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 395        return snapshot.Rows.FirstOrDefault(x => string.Equals(x.FileName, fileName, StringComparison.OrdinalIgnoreCase)
 396    }
 397
 398    private void DisableLabelCaching()
 399    {
 400        Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
 401        Response.Headers.Pragma = "no-cache";
 402    }
 403
 404    internal static string BuildInlineContentDisposition(string? fileName)
 405    {
 406        var contentDisposition = new ContentDispositionHeaderValue("inline");
 407        if (!string.IsNullOrWhiteSpace(fileName))
 408        {
 409            contentDisposition.SetHttpFileName(fileName);
 410        }
 411        return contentDisposition.ToString();
 412    }
 413
 414    private void SetInlineContentDisposition(string? fileName)
 415    {
 416        Response.Headers[HeaderNames.ContentDisposition] = BuildInlineContentDisposition(fileName);
 417    }
 418}
 419
 420public sealed class LabelDownloadRequest
 421{
 0422    public List<string> FileNames { get; init; } = [];
 423}

Methods/Properties

get_FileNames()