< Summary

Information
Class: OzonOrdersWeb.Areas.Settlements.Controllers.ArmtekController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Settlements/Controllers/ArmtekController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 187
Coverable lines: 187
Total lines: 338
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 86
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
.cctor()100%210%
Index()0%2040%
SettlementsGrid()0%620%
SettlementsFacets(...)100%210%
InvoicesGrid()0%620%
InvoicesFacets(...)100%210%
ItemsGrid()0%620%
ItemsFacets(...)100%210%
ItemsSource(...)0%4260%
Invoice()0%2040%
ReloadInvoice()100%210%
get_From()100%210%
get_To()100%210%
Sync(...)0%210140%
Import()0%342180%
SyncStatus(...)0%210140%
get_Rows()100%210%
LastRow(...)0%620%
PageAsync()0%4260%
FacetsAsync()0%4260%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Settlements/Controllers/ArmtekController.cs

#LineLine coverage
 1using System.Linq.Expressions;
 2using Hangfire;
 3using Microsoft.AspNetCore.Mvc;
 4using Microsoft.EntityFrameworkCore;
 5using OzonDomains.Models.ArmtekEtp;
 6using OzonDomains.Models.Authorization.Enums;
 7using OzonOrdersWeb.Areas.Settlements.Models;
 8using OzonOrdersWeb.Authorization;
 9using OzonOrdersWeb.WebServices.ArmtekEtp;
 10using OzonOrdersWeb.WebServices.Grid;
 11using OzonRepositories.Context;
 12using Services.ApiServices.ArmtekEtp;
 13using Services.ArmtekEtpServices;
 14
 15namespace OzonOrdersWeb.Areas.Settlements.Controllers;
 16
 17/// <summary>
 18/// Раздел «Взаиморасчёты Armtek»: данные из кабинета etp.armtek.ru, которые выгружает <see cref="ArmtekEtpSyncJob"/>.
 19/// Три серверных грида (взаиморасчёты, фактуры, позиции фактур), страница фактуры и запуск загрузки за период.
 20/// </summary>
 21[Area("Settlements")]
 22[Route("Settlements/Armtek")]
 023public sealed class ArmtekController(
 024    ApplicationDbContext db,
 025    IBackgroundJobClient jobs,
 026    ArmtekEtpSyncService sync,
 027    SettlementSuppliersOverview overview,
 028    ILogger<ArmtekController> logger) : Controller
 29{
 30    private const string PageCode = "Settlements.Armtek.Index";
 031    private static readonly string[] Tabs = ["settlements", "invoices", "items"];
 32
 33    [HttpGet("")]
 34    [PageAuthorize(PageCode, AccessType.View)]
 35    public async Task<IActionResult> Index(string? tab, CancellationToken ct)
 36    {
 037        tab = Tabs.Contains(tab) ? tab! : "settlements";
 038        var summary = await overview.GetArmtekSummaryAsync(ct);
 039        return View(new ArmtekSettlementsPageViewModel
 040        {
 041            Tab = tab,
 042            SettlementsCount = summary.SettlementsCount,
 043            InvoicesCount = summary.InvoicesCount,
 044            ItemsCount = summary.ItemsCount,
 045            LastUpdatedAtUtc = summary.LastUpdatedAtUtc,
 046            LastError = summary.LastError,
 047            LastErrorAtUtc = summary.LastErrorAtUtc,
 048            Schedule = overview.ScheduleText,
 049            Balance = summary.Balance,
 050            BalanceCurrency = summary.BalanceCurrency,
 051            BalanceDate = summary.BalanceDate,
 052            CanSync = User.IsInRole("Admin") || User.IsInRole(Services.Authorization.AuthorizationConstants.SuperAdminRo
 053        });
 054    }
 55
 56    // ---------- Взаиморасчёты ----------
 57
 58    [HttpPost("settlements/grid")]
 59    [ValidateAntiForgeryToken]
 60    [PageAuthorize(PageCode, AccessType.View)]
 61    public async Task<IActionResult> SettlementsGrid([FromBody] ServerGridRequest? request, CancellationToken ct)
 62    {
 063        var page = await PageAsync(db.ArmtekSettlements.AsNoTracking(), ArmtekEtpGridDefinitions.Settlements, request, c
 064        if (page.Error != null) return BadRequest(new { success = false, error = page.Error });
 65
 066        var vbelns = page.Rows.Select(x => x.Vbeln).Distinct().ToList();
 067        var invoices = await db.ArmtekInvoices.AsNoTracking()
 068            .Where(x => vbelns.Contains(x.Vbeln))
 069            .Select(x => new { x.Id, x.Kunnr, x.Vbeln })
 070            .ToListAsync(ct);
 071        var invoiceIds = invoices.ToDictionary(x => (x.Kunnr, x.Vbeln), x => x.Id);
 72
 073        var rows = page.Rows.Select(x => new
 074        {
 075            x.Id, x.Kunnr, x.ClientName, x.DocDate, x.Vbeln, x.DocType, x.DocTypeName, x.DocNumber, x.PrintNumber,
 076            x.PaymentMethodName, x.Amount, x.PayUntil, x.ReturnAmount, x.PaymentAmount, x.RemainderAmount, x.Balance,
 077            x.Contract, x.Currency, x.DivisionName, x.CompanyName, x.UpdatedAtUtc,
 078            HasInvoicePage = x.DocType != null && ArmtekEtpSyncService.InvoiceDocTypes.Contains(x.DocType),
 079            InvoiceId = invoiceIds.TryGetValue((x.Kunnr, x.Vbeln), out var id) ? id : (long?)null
 080        }).ToList();
 081        return Ok(new { success = true, rows, totalCount = page.Total, lastRow = page.LastRow(rows.Count) });
 082    }
 83
 84    [HttpPost("settlements/grid/facets")]
 85    [ValidateAntiForgeryToken]
 86    [PageAuthorize(PageCode, AccessType.View)]
 87    public Task<IActionResult> SettlementsFacets([FromBody] ServerGridRequest? request, CancellationToken ct) =>
 088        FacetsAsync(db.ArmtekSettlements.AsNoTracking(), ArmtekEtpGridDefinitions.Settlements, request, ct);
 89
 90    // ---------- Фактуры ----------
 91
 92    [HttpPost("invoices/grid")]
 93    [ValidateAntiForgeryToken]
 94    [PageAuthorize(PageCode, AccessType.View)]
 95    public async Task<IActionResult> InvoicesGrid([FromBody] ServerGridRequest? request, CancellationToken ct)
 96    {
 097        var page = await PageAsync(db.ArmtekInvoices.AsNoTracking(), ArmtekEtpGridDefinitions.Invoices, request, ct,
 098            x => new ArmtekInvoice
 099            {
 0100                Id = x.Id, Kunnr = x.Kunnr, Vbeln = x.Vbeln, InvoiceDate = x.InvoiceDate, CreatedAt = x.CreatedAt,
 0101                CreatedBy = x.CreatedBy, BuyerCode = x.BuyerCode, BuyerName = x.BuyerName,
 0102                UnloadingPointName = x.UnloadingPointName, DeliveryAddress = x.DeliveryAddress, Contact = x.Contact,
 0103                ReceiveMethod = x.ReceiveMethod, DeliveryStatus = x.DeliveryStatus, UpdNumber = x.UpdNumber,
 0104                TtnNumber = x.TtnNumber, TotalAmount = x.TotalAmount, TotalQuantity = x.TotalQuantity,
 0105                ReturnedQuantity = x.ReturnedQuantity, LoadedAtUtc = x.LoadedAtUtc, OrderNumber = x.OrderNumber,
 0106                OrderType = x.OrderType, OrderStatus = x.OrderStatus, ReturnReason = x.ReturnReason,
 0107                PickupPointName = x.PickupPointName, Payment = x.Payment, Contract = x.Contract, Currency = x.Currency,
 0108                MaxReturnSum = x.MaxReturnSum, Source = x.Source
 0109            });
 0110        if (page.Error != null) return BadRequest(new { success = false, error = page.Error });
 111
 0112        var rows = page.Rows.Select(x => new
 0113        {
 0114            x.Id, x.Kunnr, x.Vbeln, x.InvoiceDate, x.CreatedAt, x.CreatedBy, x.BuyerCode, x.BuyerName,
 0115            x.UnloadingPointName, x.DeliveryAddress, x.Contact, x.ReceiveMethod, x.DeliveryStatus, x.UpdNumber,
 0116            x.TtnNumber, x.TotalAmount, x.TotalQuantity, x.ReturnedQuantity, x.LoadedAtUtc, x.OrderNumber, x.OrderType,
 0117            x.OrderStatus, x.ReturnReason, x.PickupPointName, x.Payment, x.Contract, x.Currency, x.MaxReturnSum,
 0118            Source = x.Source == ArmtekEtpSyncService.ApiSource ? "API" : "Кабинет"
 0119        }).ToList();
 0120        return Ok(new { success = true, rows, totalCount = page.Total, lastRow = page.LastRow(rows.Count) });
 0121    }
 122
 123    [HttpPost("invoices/grid/facets")]
 124    [ValidateAntiForgeryToken]
 125    [PageAuthorize(PageCode, AccessType.View)]
 126    public Task<IActionResult> InvoicesFacets([FromBody] ServerGridRequest? request, CancellationToken ct) =>
 0127        FacetsAsync(db.ArmtekInvoices.AsNoTracking(), ArmtekEtpGridDefinitions.Invoices, request, ct);
 128
 129    // ---------- Позиции фактур ----------
 130
 131    [HttpPost("items/grid")]
 132    [ValidateAntiForgeryToken]
 133    [PageAuthorize(PageCode, AccessType.View)]
 134    public async Task<IActionResult> ItemsGrid([FromBody] ServerGridRequest? request, CancellationToken ct)
 135    {
 0136        var page = await PageAsync(ItemsSource(request), ArmtekEtpGridDefinitions.Items, request, ct,
 0137            x => new ArmtekInvoiceItem
 0138            {
 0139                Id = x.Id, InvoiceId = x.InvoiceId, Position = x.Position, ArmtekCode = x.ArmtekCode, Article = x.Articl
 0140                Brand = x.Brand, Name = x.Name, FullName = x.FullName, Price = x.Price, Quantity = x.Quantity,
 0141                Returned = x.Returned, Amount = x.Amount, OrderNumber = x.OrderNumber, OrderPosition = x.OrderPosition,
 0142                Note = x.Note, Gtd = x.Gtd, Country = x.Country, MaxReturnDate = x.MaxReturnDate, NoReturn = x.NoReturn,
 0143                ReturnDays = x.ReturnDays, VatRate = x.VatRate, VatAmount = x.VatAmount, Ean = x.Ean, Tnved = x.Tnved, W
 0144                Invoice = new ArmtekInvoice
 0145                {
 0146                    Kunnr = x.Invoice!.Kunnr, Vbeln = x.Invoice.Vbeln, InvoiceDate = x.Invoice.InvoiceDate,
 0147                    OrderType = x.Invoice.OrderType, ReturnReason = x.Invoice.ReturnReason
 0148                }
 0149            });
 0150        if (page.Error != null) return BadRequest(new { success = false, error = page.Error });
 151
 0152        var rows = page.Rows.Select(x => new
 0153        {
 0154            x.Id, x.InvoiceId, x.Invoice!.Kunnr, x.Invoice.Vbeln, x.Invoice.InvoiceDate, x.Position, x.ArmtekCode,
 0155            x.Article, x.Brand, x.Name, x.FullName, x.Price, x.Quantity, x.Returned, x.Amount, x.OrderNumber,
 0156            x.OrderPosition, x.Note, x.Gtd, x.Country, x.MaxReturnDate, x.NoReturn, x.ReturnDays, x.VatRate, x.VatAmount
 0157            x.Ean, x.Tnved, x.Weight, x.Invoice.OrderType, x.Invoice.ReturnReason
 0158        }).ToList();
 0159        return Ok(new { success = true, rows, totalCount = page.Total, lastRow = page.LastRow(rows.Count) });
 0160    }
 161
 162    [HttpPost("items/grid/facets")]
 163    [ValidateAntiForgeryToken]
 164    [PageAuthorize(PageCode, AccessType.View)]
 165    public Task<IActionResult> ItemsFacets([FromBody] ServerGridRequest? request, CancellationToken ct) =>
 0166        FacetsAsync(ItemsSource(request), ArmtekEtpGridDefinitions.Items, request, ct);
 167
 168    /// <summary>Позиции одной фактуры, если грид открыт со страницы фактуры (?invoiceId=).</summary>
 169    private IQueryable<ArmtekInvoiceItem> ItemsSource(ServerGridRequest? request)
 170    {
 0171        IQueryable<ArmtekInvoiceItem> source = db.ArmtekInvoiceItems.AsNoTracking();
 0172        if (request != null && request.Params.TryGetValue("invoiceId", out var raw) && long.TryParse(raw, out var invoic
 0173            source = source.Where(x => x.InvoiceId == invoiceId);
 0174        return source;
 175    }
 176
 177    // ---------- Фактура ----------
 178
 179    [HttpGet("Invoice/{kunnr}/{vbeln}")]
 180    [PageAuthorize(PageCode, AccessType.View)]
 181    public async Task<IActionResult> Invoice(string kunnr, string vbeln, CancellationToken ct)
 182    {
 0183        var invoice = await db.ArmtekInvoices.AsNoTracking()
 0184            .SingleOrDefaultAsync(x => x.Kunnr == kunnr && x.Vbeln == vbeln, ct);
 0185        var settlement = await db.ArmtekSettlements.AsNoTracking()
 0186            .SingleOrDefaultAsync(x => x.Kunnr == kunnr && x.Vbeln == vbeln, ct);
 0187        if (invoice == null && settlement == null)
 0188            return NotFound();
 189
 0190        return View(ArmtekInvoicePageViewModel.Build(kunnr, vbeln, invoice, settlement));
 0191    }
 192
 193    [HttpPost("Invoice/{kunnr}/{vbeln}/reload")]
 194    [ValidateAntiForgeryToken]
 195    [PageAuthorize(PageCode, AccessType.Edit)]
 196    public async Task<IActionResult> ReloadInvoice(string kunnr, string vbeln, CancellationToken ct)
 197    {
 198        try
 199        {
 0200            await sync.LoadInvoiceAsync(kunnr, vbeln, ct);
 0201            TempData["Success"] = $"Фактура {vbeln} загружена из кабинета Armtek.";
 0202        }
 0203        catch (Exception ex) when (ex is ArmtekEtpException or HttpRequestException or TaskCanceledException)
 204        {
 0205            logger.LogWarning(ex, "Armtek ETP: фактура {Kunnr}/{Vbeln} не загружена со страницы", kunnr, vbeln);
 0206            TempData["Error"] = $"Не удалось загрузить фактуру {vbeln}: {ex.Message}";
 0207        }
 0208        return RedirectToAction(nameof(Invoice), new { kunnr, vbeln });
 0209    }
 210
 211    // ---------- Загрузка из кабинета ----------
 212
 213    public sealed class SyncRequest
 214    {
 0215        public DateTime? From { get; set; }
 0216        public DateTime? To { get; set; }
 217    }
 218
 219    [HttpPost("sync")]
 220    [ValidateAntiForgeryToken]
 221    [PageAuthorize(PageCode, AccessType.Edit)]
 222    public IActionResult Sync([FromBody] SyncRequest? request)
 223    {
 0224        var to = (request?.To ?? DateTime.Today).Date;
 0225        var from = (request?.From ?? to.AddDays(-45)).Date;
 0226        if (from > to) return BadRequest(new { success = false, error = "Дата «с» позже даты «по»." });
 227
 228        // Период не ограничиваем: кабинет отдаёт взаиморасчёты за любой срок одним ответом. Долго идут только фактуры,
 229        // и то один раз — уже загруженные старые фактуры повторно не читаются.
 0230        var user = User.Identity?.Name;
 0231        var jobId = jobs.Enqueue<ArmtekEtpSyncJob>(j => j.ExecuteRangeAsync(from, to, user));
 0232        logger.LogInformation("Armtek ETP: загрузка {From:dd.MM.yyyy}–{To:dd.MM.yyyy} поставлена ({JobId}) пользователем
 0233            from, to, jobId, User.Identity?.Name);
 0234        return Ok(new { success = true, jobId });
 235    }
 236
 237    /// <summary>
 238    /// Взаиморасчёты из Excel (кабинет → Взаиморасчёты → Выгрузить файл → .xlsx) — когда автозагрузка из кабинета
 239    /// упирается в капчу. dryRun=true только проверяет файл; иначе строки импортируются, а фактуры этих документов
 240    /// дочитываются через API фоновой задачей (связь документ ↔ фактура по коду документа).
 241    /// </summary>
 242    [HttpPost("import")]
 243    [ValidateAntiForgeryToken]
 244    [RequestSizeLimit(MaxImportBytes)]
 245    [PageAuthorize(PageCode, AccessType.Edit)]
 246    public async Task<IActionResult> Import(IFormFile? file, bool dryRun, CancellationToken ct)
 247    {
 0248        if (file == null || file.Length == 0)
 0249            return Ok(new { success = false, errors = new[] { "Выберите файл .xlsx с взаиморасчётами." } });
 0250        if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
 0251            return Ok(new { success = false, errors = new[] { $"Нужен файл .xlsx, а выбран «{file.FileName}»." } });
 0252        if (file.Length > MaxImportBytes)
 0253            return Ok(new { success = false, errors = new[] { "Файл больше 10 МБ — это не выгрузка взаиморасчётов." } })
 254
 0255        await using var buffer = new MemoryStream();
 0256        await file.CopyToAsync(buffer, ct);
 0257        buffer.Position = 0;
 0258        var parsed = ArmtekSettlementsExcelReader.Read(buffer);
 0259        var summary = new
 0260        {
 0261            rows = parsed.Rows.Count,
 0262            periodFrom = parsed.PeriodFrom,
 0263            periodTo = parsed.PeriodTo,
 0264            payers = parsed.Rows.Select(r => r.Kunnr).Distinct().ToArray(),
 0265            docTypes = parsed.Rows.GroupBy(r => r.DocTypeName).Select(g => new { name = g.Key, count = g.Count() }).Orde
 0266            lastBalance = parsed.Rows.OrderBy(r => r.DocDate).ThenBy(r => r.RowNumber).LastOrDefault()?.Balance,
 0267            parsed.Warnings
 0268        };
 0269        if (!parsed.IsValid)
 0270            return Ok(new { success = false, errors = parsed.Errors, summary });
 0271        if (dryRun)
 0272            return Ok(new { success = true, dryRun = true, summary });
 273
 0274        var user = User.Identity?.Name;
 0275        var imported = await sync.ImportSettlementsAsync(parsed.Rows, Path.GetFileName(file.FileName), user, ct);
 0276        var jobId = jobs.Enqueue<ArmtekEtpSyncJob>(j => j.ExecuteInvoicesAsync(imported.PeriodFrom, imported.PeriodTo, u
 0277        logger.LogInformation("Armtek: {Result} — пользователь {User}, фактуры к ним дочитывает задача {JobId}", importe
 0278        return Ok(new { success = true, summary, imported = imported.ToString(), created = imported.Created, updated = i
 0279    }
 280
 281    private const int MaxImportBytes = 10 * 1024 * 1024;
 282
 283    [HttpGet("sync/{jobId}")]
 284    [PageAuthorize(PageCode, AccessType.View)]
 285    public IActionResult SyncStatus(string jobId)
 286    {
 0287        var details = JobStorage.Current.GetMonitoringApi().JobDetails(jobId);
 0288        if (details == null) return NotFound(new { success = false, error = "Задача не найдена." });
 289
 0290        var last = details.History.FirstOrDefault();
 0291        var state = last?.StateName ?? "Enqueued";
 0292        string? message = null;
 0293        if (last?.Data != null)
 294        {
 0295            last.Data.TryGetValue("Result", out var result);
 0296            last.Data.TryGetValue("ExceptionMessage", out var error);
 0297            message = (error ?? result)?.Trim('"');
 298        }
 0299        return Ok(new { success = true, state, message });
 300    }
 301
 302    // ---------- Общее ----------
 303
 0304    private sealed record GridPage<T>(IReadOnlyList<T> Rows, int Total, int Start, string? Error)
 305    {
 0306        public int LastRow(int count) => Start + count >= Total ? Total : -1;
 307    }
 308
 309    private static async Task<GridPage<T>> PageAsync<T>(IQueryable<T> source, ServerGridDefinition<T> def,
 310        ServerGridRequest? request, CancellationToken ct, Expression<Func<T, T>>? projection = null)
 311    {
 0312        var error = ServerGridQuery.Validate(request);
 0313        if (error != null) return new GridPage<T>([], 0, 0, error);
 314
 0315        var filtered = ServerGridQuery.ApplyFilters(source, def, request!);
 0316        var total = await filtered.CountAsync(ct);
 0317        var start = Math.Max(0, request!.StartRow);
 0318        var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 0319        if (start >= total) return new GridPage<T>([], total, start, null);
 320
 0321        var sorted = ServerGridQuery.ApplySort(filtered, def, request.SortModel).Skip(start).Take(take);
 0322        var rows = projection == null ? await sorted.ToListAsync(ct) : await sorted.Select(projection).ToListAsync(ct);
 0323        return new GridPage<T>(rows, total, start, null);
 0324    }
 325
 326    private static async Task<IActionResult> FacetsAsync<T>(IQueryable<T> source, ServerGridDefinition<T> def,
 327        ServerGridRequest? request, CancellationToken ct)
 328    {
 0329        if (request is null || string.IsNullOrWhiteSpace(request.ColId))
 0330            return new BadRequestObjectResult(new { success = false, error = "colId обязателен" });
 0331        request.StartRow = 0;
 0332        request.EndRow = 1;
 0333        var error = ServerGridQuery.Validate(request);
 0334        if (error != null) return new BadRequestObjectResult(new { success = false, error });
 0335        var values = await ServerGridQuery.FacetsAsync(source, def, request, ct);
 0336        return new OkObjectResult(new { success = true, values });
 0337    }
 338}