< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.LabelsController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/LabelsController.cs
Line coverage
40%
Covered lines: 92
Uncovered lines: 133
Coverable lines: 225
Total lines: 423
Line coverage: 40.8%
Branch coverage
10%
Covered branches: 9
Total branches: 86
Branch coverage: 10.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
Index()100%210%
GridRows()50%66100%
GridFacets()0%272160%
RefreshCatalog()100%210%
DownloadFile()0%620%
Preview()50%2277.77%
DownloadSelected()0%930300%
Download()100%1162.5%
GetThumbnail()100%210%
GetPdfForPreview()100%210%
GetDownloadLink(...)100%210%
GetPublicLink(...)100%210%
CheckLabelExists()0%620%
CheckLabelsExist()0%2040%
InvalidateLabelCache(...)100%210%
SetLabelCache(...)100%210%
Delete()100%210%
MigrateFromDropbox()100%210%
ApplyCatalogQuery(...)50%44100%
MatchesSearch(...)0%156120%
Contains(...)0%620%
StringFacets(...)0%620%
ToGridRow(...)100%11100%
FindCatalogFileAsync()50%44100%
DisableLabelCaching()100%11100%
BuildInlineContentDisposition(...)100%22100%
SetInlineContentDisposition(...)100%11100%

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
 117    private static readonly ServerGridDefinition<LabelCatalogRow> GridDefinition =
 118        new ServerGridDefinition<LabelCatalogRow>
 119        {
 320            DefaultOrder = q => q.OrderByDescending(x => x.PrintedAtUtc).ThenBy(x => x.FileName),
 121            TieBreaker = x => x.Id
 122        }
 123        .Col("fileName", x => x.FileName)
 124        .Col("shipmentNumber", x => x.ShipmentNumber)
 125        .Col("scanit", x => x.Scanit)
 126        .Col("client", x => x.Client)
 127        .Col("marketplace", x => x.Marketplace)
 128        .Col("linked", x => x.HasOrder)
 129        .Col("printedAtUtc", x => x.PrintedAtUtc)
 130        .Col("printedBy", x => x.PrintedBy)
 131        .Col("printSource", x => x.PrintSource)
 132        .Col("modifiedAtUtc", x => x.ModifiedAtUtc)
 133        .Col("sizeBytes", x => x.SizeBytes)
 134        .Col("orderId", x => x.OrderId);
 35
 36    private readonly ILabelFileStorage _labelFileStorage;
 37    private readonly ILabelExistenceService _labelExistenceService;
 38    private readonly ILabelCatalogService _labelCatalog;
 39
 340    public LabelsController(
 341        ILabelFileStorage labelFileStorage,
 342        ILabelExistenceService labelExistenceService,
 343        ILabelCatalogService labelCatalog)
 44    {
 345        _labelFileStorage = labelFileStorage;
 346        _labelExistenceService = labelExistenceService;
 347        _labelCatalog = labelCatalog;
 348    }
 49
 50    [HttpGet]
 051    public IActionResult Index() => View();
 52
 53    [HttpPost]
 54    [ValidateAntiForgeryToken]
 55    public async Task<IActionResult> GridRows([FromBody] ServerGridRequest? request, CancellationToken cancellationToken
 56    {
 357        var error = ServerGridQuery.Validate(request);
 358        if (error != null) return BadRequest(new { success = false, error });
 59
 360        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 361        var filtered = ApplyCatalogQuery(snapshot.Rows, request!);
 362        var total = filtered.Count();
 363        var start = Math.Max(0, request!.StartRow);
 364        var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 365        var rows = start >= total
 366            ? []
 367            : ServerGridQuery.ApplySort(filtered, GridDefinition, request.SortModel)
 368                .Skip(start)
 369                .Take(take)
 370                .Select(ToGridRow)
 371                .ToList();
 372        var lastRow = start + rows.Count >= total ? total : -1;
 373        return Ok(new { success = true, rows, totalCount = total, lastRow, refreshedAtUtc = snapshot.RefreshedAtUtc });
 374    }
 75
 76    [HttpPost]
 77    [ValidateAntiForgeryToken]
 78    public async Task<IActionResult> GridFacets([FromBody] ServerGridRequest? request, CancellationToken cancellationTok
 79    {
 080        if (request is null || string.IsNullOrWhiteSpace(request.ColId))
 081            return BadRequest(new { success = false, error = "colId обязателен." });
 82
 083        request.StartRow = 0;
 084        request.EndRow = 1;
 085        var error = ServerGridQuery.Validate(request);
 086        if (error != null) return BadRequest(new { success = false, error });
 87
 088        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 089        var filtered = ApplyCatalogQuery(snapshot.Rows, request, request.ColId).ToList();
 090        var values = request.ColId switch
 091        {
 092            "client" => StringFacets(filtered, x => x.Client),
 093            "marketplace" => StringFacets(filtered, x => x.Marketplace),
 094            "printedBy" => StringFacets(filtered, x => x.PrintedBy),
 095            "printSource" => StringFacets(filtered, x => x.PrintSource),
 096            "linked" => filtered.GroupBy(x => x.HasOrder ? "true" : "false")
 097                .Select(g => new ServerGridFacet { Value = g.Key, Count = g.Count() })
 098                .OrderByDescending(x => x.Count)
 099                .ToList(),
 0100            _ => []
 0101        };
 0102        return Ok(new { success = true, values });
 0103    }
 104
 105    [HttpPost]
 106    [ValidateAntiForgeryToken]
 107    public async Task<IActionResult> RefreshCatalog(CancellationToken cancellationToken)
 108    {
 0109        var snapshot = await _labelCatalog.RefreshAsync(cancellationToken);
 0110        return Ok(new { success = true, totalCount = snapshot.Rows.Count, refreshedAtUtc = snapshot.RefreshedAtUtc });
 0111    }
 112
 113    /// <summary>Отдаёт PDF как attachment только для новой страницы каталога.</summary>
 114    [HttpGet]
 115    public async Task<IActionResult> DownloadFile(string fileName, CancellationToken cancellationToken)
 116    {
 0117        var validFile = await FindCatalogFileAsync(fileName, cancellationToken);
 0118        if (validFile is null) return NotFound("Этикетка не найдена.");
 119
 120        try
 121        {
 0122            var download = await _labelFileStorage.OpenReadAsync(validFile, cancellationToken);
 0123            DisableLabelCaching();
 0124            return File(download.Content, "application/pdf", validFile, enableRangeProcessing: true);
 125        }
 0126        catch (FileNotFoundException)
 127        {
 0128            return NotFound("Этикетка больше недоступна. Обновите каталог.");
 129        }
 0130    }
 131
 132    /// <summary>Защищённое inline-превью PDF для модального окна каталога.</summary>
 133    [HttpGet]
 134    public async Task<IActionResult> Preview(string fileName, CancellationToken cancellationToken)
 135    {
 1136        var validFile = await FindCatalogFileAsync(fileName, cancellationToken);
 1137        if (validFile is null) return NotFound("Этикетка не найдена.");
 138
 139        try
 140        {
 1141            var download = await _labelFileStorage.OpenReadAsync(validFile, cancellationToken);
 1142            DisableLabelCaching();
 1143            SetInlineContentDisposition(validFile);
 1144            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 145        }
 0146        catch (FileNotFoundException)
 147        {
 0148            return NotFound("Этикетка больше недоступна. Обновите каталог.");
 149        }
 1150    }
 151
 152    [HttpPost]
 153    [ValidateAntiForgeryToken]
 154    public async Task<IActionResult> DownloadSelected(
 155        [FromBody] LabelDownloadRequest? request,
 156        CancellationToken cancellationToken)
 157    {
 0158        var requested = request?.FileNames?
 0159            .Where(x => !string.IsNullOrWhiteSpace(x))
 0160            .Distinct(StringComparer.OrdinalIgnoreCase)
 0161            .ToList() ?? [];
 0162        if (requested.Count == 0)
 0163            return BadRequest(new { success = false, message = "Выберите хотя бы одну этикетку." });
 0164        if (requested.Count > MaxSelectedFiles)
 0165            return BadRequest(new { success = false, message = $"За один раз можно скачать не более {MaxSelectedFiles} э
 166
 0167        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 0168        var allowed = snapshot.Rows.Select(x => x.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase);
 0169        if (requested.Any(x => !allowed.Contains(x)))
 0170            return BadRequest(new { success = false, message = "Одна из выбранных этикеток больше недоступна." });
 171
 0172        var files = new List<(string Name, byte[] Content)>();
 0173        long totalBytes = 0;
 174        try
 175        {
 0176            foreach (var fileName in requested)
 177            {
 0178                var bytes = await _labelFileStorage.TryDownloadAsync(fileName, cancellationToken);
 0179                if (bytes is not { Length: > 0 })
 0180                    return BadRequest(new { success = false, message = $"Этикетка «{fileName}» отсутствует или пуста." }
 181
 0182                totalBytes += bytes.Length;
 0183                if (totalBytes > MaxMergedBytes)
 0184                    return BadRequest(new { success = false, message = "Размер выбранных этикеток превышает 100 МБ." });
 0185                files.Add((fileName, bytes));
 0186            }
 187
 0188            await using var result = new MemoryStream();
 0189            using (var merged = new PdfDocument(new PdfWriter(result)))
 190            {
 0191                foreach (var (_, content) in files)
 192                {
 0193                    await using var source = new MemoryStream(content);
 0194                    using var pdf = new PdfDocument(new PdfReader(source));
 0195                    if (pdf.GetNumberOfPages() <= 0)
 0196                        return BadRequest(new { success = false, message = "Одна из выбранных этикеток не содержит стран
 0197                    pdf.CopyPagesTo(1, pdf.GetNumberOfPages(), merged);
 198                }
 0199            }
 200
 0201            DisableLabelCaching();
 0202            return File(result.ToArray(), "application/pdf", $"labels_{DateTime.UtcNow:yyyyMMdd_HHmmss}.pdf");
 0203        }
 0204        catch (Exception ex) when (ex is not OperationCanceledException)
 205        {
 0206            return BadRequest(new { success = false, message = "Не удалось объединить выбранные PDF: " + ex.Message });
 207        }
 0208    }
 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        {
 1219            var download = await _labelFileStorage.OpenReadAsync(fileName);
 1220            DisableLabelCaching();
 1221            SetInlineContentDisposition(fileName);
 1222            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 223        }
 0224        catch (Exception ex)
 225        {
 0226            TempData["Error"] = $"Ошибка при скачивании файла: {ex.Message}";
 0227            return RedirectToAction(nameof(Index));
 228        }
 1229    }
 230
 231    [HttpGet]
 232    public async Task<IActionResult> GetThumbnail(string fileName)
 233    {
 234        try
 235        {
 0236            var download = await _labelFileStorage.OpenReadAsync(fileName);
 0237            DisableLabelCaching();
 0238            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 239        }
 0240        catch (Exception ex)
 241        {
 0242            return NotFound($"Ошибка при загрузке миниатюры: {ex.Message}");
 243        }
 0244    }
 245
 246    [HttpGet]
 247    public async Task<IActionResult> GetPdfForPreview(string fileName)
 248    {
 249        try
 250        {
 0251            var download = await _labelFileStorage.OpenReadAsync(fileName);
 0252            DisableLabelCaching();
 0253            return File(download.Content, "application/pdf", enableRangeProcessing: true);
 254        }
 0255        catch (Exception ex)
 256        {
 0257            return NotFound($"Ошибка при загрузке PDF: {ex.Message}");
 258        }
 0259    }
 260
 261    [HttpGet]
 262    public IActionResult GetDownloadLink(string fileName)
 263    {
 0264        var downloadLink = Url.Action(nameof(Download), "Labels", new { fileName }, Request.Scheme);
 0265        return Json(new { success = true, downloadLink });
 266    }
 267
 268    [HttpGet]
 269    public IActionResult GetPublicLink(string fileName)
 270    {
 0271        var publicLink = Url.Action(nameof(Download), "Labels", new { fileName }, Request.Scheme);
 0272        return Json(new { success = true, publicLink });
 273    }
 274
 275    [HttpGet]
 276    public async Task<IActionResult> CheckLabelExists(string shipmentNumber)
 277    {
 0278        if (string.IsNullOrWhiteSpace(shipmentNumber))
 0279            return Json(new { success = false, message = "ShipmentNumber не указан" });
 280
 281        try
 282        {
 0283            var results = await _labelExistenceService.CheckBatchAsync([shipmentNumber]);
 0284            return Json(new { success = true, exists = results.TryGetValue(shipmentNumber, out var exists) && exists, fr
 285        }
 0286        catch (Exception ex)
 287        {
 0288            return Json(new { success = false, message = $"Ошибка при проверке этикетки: {ex.Message}", exists = false }
 289        }
 0290    }
 291
 292    [HttpPost]
 293    public async Task<IActionResult> CheckLabelsExist([FromBody] string[] shipmentNumbers)
 294    {
 295        try
 296        {
 0297            var results = shipmentNumbers is { Length: > 0 }
 0298                ? await _labelExistenceService.CheckBatchAsync(shipmentNumbers)
 0299                : new Dictionary<string, bool>();
 0300            return Json(new { success = true, results });
 301        }
 0302        catch (Exception ex)
 303        {
 0304            return Json(new { success = false, message = $"Ошибка при проверке этикеток: {ex.Message}", results = new Di
 305        }
 0306    }
 307
 0308    public void InvalidateLabelCache(string shipmentNumber) => _labelExistenceService.InvalidateShipment(shipmentNumber)
 309
 0310    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        {
 0318            await _labelFileStorage.DeleteAsync(fileName);
 0319            _labelExistenceService.InvalidateShipment(Path.GetFileNameWithoutExtension(fileName));
 0320            TempData["Success"] = $"Файл «{fileName}» удалён";
 0321        }
 0322        catch (Exception ex)
 323        {
 0324            TempData["Error"] = $"Ошибка при удалении файла: {ex.Message}";
 0325        }
 326
 0327        return RedirectToAction(nameof(Index));
 0328    }
 329
 330    [HttpPost]
 331    [Authorize(Roles = "Admin")]
 332    public IActionResult MigrateFromDropbox()
 333    {
 0334        var jobId = Hangfire.BackgroundJob.Enqueue<DropboxToServerFileStorageMigrationJob>(j => j.ExecuteAsync());
 0335        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    {
 3343        var search = request.Search?.Trim();
 3344        var query = string.IsNullOrWhiteSpace(search)
 3345            ? rows
 3346            : rows.Where(x => MatchesSearch(x, search));
 3347        return ServerGridQuery.ApplyFilters(query.AsQueryable(), GridDefinition, request, excludeColId);
 348    }
 349
 350    private static bool MatchesSearch(LabelCatalogRow row, string search) =>
 0351        string.Equals(row.Scanit, search, StringComparison.OrdinalIgnoreCase) ||
 0352        Contains(row.FileName, search) ||
 0353        Contains(row.ShipmentNumber, search) ||
 0354        Contains(row.Scanit, search) ||
 0355        Contains(row.PrintedBy, search) ||
 0356        Contains(row.PrintSource, search) ||
 0357        Contains(row.OrderIdText, search);
 358
 359    private static bool Contains(string? value, string search) =>
 0360        value?.Contains(search, StringComparison.OrdinalIgnoreCase) == true;
 361
 362    private static List<ServerGridFacet> StringFacets(
 363        IEnumerable<LabelCatalogRow> rows,
 364        Func<LabelCatalogRow, string?> selector) =>
 0365        rows.GroupBy(x => selector(x) ?? string.Empty, StringComparer.OrdinalIgnoreCase)
 0366            .Select(g => new ServerGridFacet { Value = g.Key, Count = g.Count() })
 0367            .OrderByDescending(x => x.Count)
 0368            .ThenBy(x => x.Value, StringComparer.OrdinalIgnoreCase)
 0369            .ToList();
 370
 3371    private static object ToGridRow(LabelCatalogRow row) => new
 3372    {
 3373        row.Id,
 3374        row.FileName,
 3375        row.ShipmentNumber,
 3376        row.SizeBytes,
 3377        row.ModifiedAtUtc,
 3378        row.PrintedAtUtc,
 3379        row.UsesFileDate,
 3380        row.PrintedBy,
 3381        row.PrintSource,
 3382        row.OrderId,
 3383        row.RelatedOrderIds,
 3384        row.Scanit,
 3385        row.Client,
 3386        row.Marketplace,
 3387        row.HasOrder,
 3388        row.RelatedOrderCount
 3389    };
 390
 391    private async Task<string?> FindCatalogFileAsync(string? fileName, CancellationToken cancellationToken)
 392    {
 1393        if (string.IsNullOrWhiteSpace(fileName)) return null;
 1394        var snapshot = await _labelCatalog.GetSnapshotAsync(cancellationToken: cancellationToken);
 2395        return snapshot.Rows.FirstOrDefault(x => string.Equals(x.FileName, fileName, StringComparison.OrdinalIgnoreCase)
 1396    }
 397
 398    private void DisableLabelCaching()
 399    {
 2400        Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
 2401        Response.Headers.Pragma = "no-cache";
 2402    }
 403
 404    internal static string BuildInlineContentDisposition(string? fileName)
 405    {
 7406        var contentDisposition = new ContentDispositionHeaderValue("inline");
 7407        if (!string.IsNullOrWhiteSpace(fileName))
 408        {
 4409            contentDisposition.SetHttpFileName(fileName);
 410        }
 7411        return contentDisposition.ToString();
 412    }
 413
 414    private void SetInlineContentDisposition(string? fileName)
 415    {
 2416        Response.Headers[HeaderNames.ContentDisposition] = BuildInlineContentDisposition(fileName);
 2417    }
 418}
 419
 420public sealed class LabelDownloadRequest
 421{
 422    public List<string> FileNames { get; init; } = [];
 423}