< Summary

Information
Class: Services.ArmtekEtpServices.ArmtekExcelImportResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ArmtekEtpServices/ArmtekEtpSyncService.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 629
Line coverage: 100%
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_Rows()100%11100%
ToString()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ArmtekEtpServices/ArmtekEtpSyncService.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.Json;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Logging;
 5using OzonDomains.Models.ArmtekEtp;
 6using OzonRepositories.Context;
 7using Services.ApiServices.ArmtekApi;
 8using Services.ApiServices.ArmtekApi.Models.Response;
 9using ArmtekInvoiceItem = OzonDomains.Models.ArmtekEtp.ArmtekInvoiceItem;
 10using Services.ApiServices.ArmtekEtp;
 11
 12namespace Services.ArmtekEtpServices;
 13
 14public sealed record ArmtekEtpSyncResult(int Settlements, int NewSettlements, int Invoices, int InvoiceErrors,
 15    string? SettlementsError = null, int InvoicesRemaining = 0, bool SettlementsSkipped = false)
 16{
 17    public override string ToString() =>
 18        $"Фактур по API: {Invoices} (ошибок {InvoiceErrors})"
 19        + (InvoicesRemaining > 0 ? $", ещё {InvoicesRemaining} дочитаются следующими запусками" : string.Empty)
 20        + (SettlementsSkipped ? string.Empty : $"; взаиморасчётов из кабинета: {Settlements} (новых {NewSettlements})")
 21        + (SettlementsError == null ? string.Empty : $"; взаиморасчёты не получены: {SettlementsError}");
 22}
 23
 24/// <summary>Итог импорта взаиморасчётов из Excel.</summary>
 925public sealed record ArmtekExcelImportResult(int Rows, int Created, int Updated, DateTime PeriodFrom, DateTime PeriodTo)
 26{
 27    public override string ToString() =>
 128        $"Взаиморасчёты из Excel: {Rows} строк ({Created} новых, {Updated} обновлено) за {PeriodFrom:dd.MM.yyyy}–{Period
 29}
 30
 31/// <summary>
 32/// Загрузка из Armtek в ArmtekInvoices / ArmtekInvoiceItems / ArmtekSettlements, двумя независимыми шагами:
 33/// <list type="number">
 34/// <item>Фактуры, возвраты и позиции — официальное API ws.armtek.ru: отчёт по заказам за период (номера фактур)
 35/// и getInvoice по каждой. Без сессий и капчи, поэтому основной источник.</item>
 36/// <item>Взаиморасчёты и сальдо — личный кабинет etp.armtek.ru (в API таких методов нет). Сбой кабинета (капча,
 37/// отказ в доступе) не останавливает загрузку фактур и пишется в журнал запусков отдельной ошибкой.</item>
 38/// </list>
 39/// Повторный запуск обновляет строки по ключу (Kunnr, Vbeln), позиции фактуры перезаписываются целиком.
 40/// </summary>
 41public sealed class ArmtekEtpSyncService
 42{
 43    /// <summary>Типы документов, у которых в кабинете есть страница фактуры (список из кода сайта).</summary>
 44    public static readonly IReadOnlySet<string> InvoiceDocTypes = new HashSet<string>(StringComparer.Ordinal)
 45    {
 46        "ZROZ", "ZROS", "ZWRE", "ZRED", "ZRW", "ZCRE", "ZF2", "RE", "ZFSE", "ZFW", "ZFB", "ZPLE", "ZPLI",
 47        "ZFKE", "ZFKX", "ZFKW", "ZFWX", "ZSRE"
 48    };
 49
 50    private static readonly JsonSerializerOptions JsonOptions = new()
 51    {
 52        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
 53    };
 54
 55    private readonly ApplicationDbContext _context;
 56    private readonly ArmtekEtpClient _client;
 57    private readonly ArmtekByDataManager _api;
 58    private readonly ArmtekEtpConfig _config;
 59    private readonly ILogger<ArmtekEtpSyncService> _logger;
 60
 61    public ArmtekEtpSyncService(ApplicationDbContext context, ArmtekEtpClient client, ArmtekByDataManager api,
 62        ArmtekEtpConfig config, ILogger<ArmtekEtpSyncService> logger)
 63    {
 64        _context = context;
 65        _client = client;
 66        _api = api;
 67        _config = config;
 68        _logger = logger;
 69    }
 70
 71    public const string ScheduleTrigger = "Расписание";
 72
 73    /// <summary>
 74    /// Загрузка за период (по умолчанию — последние LookbackDays дней). Каждый запуск пишется в ArmtekEtpSyncRuns:
 75    /// по нему страница показывает «Обновлено» и последнюю ошибку.
 76    /// </summary>
 77    public async Task<ArmtekEtpSyncResult> SyncAsync(DateOnly? from = null, DateOnly? to = null, CancellationToken ct = 
 78        string trigger = ScheduleTrigger, bool includeSettlements = true)
 79    {
 80        var today = DateOnly.FromDateTime(DateTime.Today);
 81        var periodTo = to ?? today;
 82        var periodFrom = from ?? periodTo.AddDays(-Math.Max(1, _config.LookbackDays));
 83
 84        var run = new ArmtekEtpSyncRun
 85        {
 86            StartedAtUtc = DateTime.UtcNow,
 87            PeriodFrom = periodFrom.ToDateTime(TimeOnly.MinValue),
 88            PeriodTo = periodTo.ToDateTime(TimeOnly.MinValue),
 89            Trigger = Cut(trigger, 256) ?? ScheduleTrigger
 90        };
 91        _context.ArmtekEtpSyncRuns.Add(run);
 92        await _context.SaveChangesAsync(ct);
 93        var runId = run.Id;
 94
 95        try
 96        {
 97            var result = await RunAsync(periodFrom, periodTo, includeSettlements, ct);
 98            await FinishRunAsync(runId, true, result.ToString(), result);
 99            return result;
 100        }
 101        catch (ArmtekSyncPartialException partial)
 102        {
 103            await FinishRunAsync(runId, false, partial.Message, partial.Result);
 104            throw;
 105        }
 106        catch (Exception ex)
 107        {
 108            await FinishRunAsync(runId, false, ex.Message, null);
 109            throw;
 110        }
 111    }
 112
 113    /// <summary>Итог запуска пишется отдельно от основной работы: даже после сбоя и при отменённом токене.</summary>
 114    private async Task FinishRunAsync(long runId, bool success, string message, ArmtekEtpSyncResult? result)
 115    {
 116        try
 117        {
 118            _context.ChangeTracker.Clear();
 119            var run = await _context.ArmtekEtpSyncRuns.FindAsync(runId);
 120            if (run == null) return;
 121            run.FinishedAtUtc = DateTime.UtcNow;
 122            run.Success = success;
 123            run.Message = Cut(message, 2000);
 124            if (result != null)
 125            {
 126                run.Settlements = result.Settlements;
 127                run.NewSettlements = result.NewSettlements;
 128                run.Invoices = result.Invoices;
 129                run.InvoiceErrors = result.InvoiceErrors;
 130                run.SettlementsError = Cut(result.SettlementsError, 2000);
 131            }
 132            await _context.SaveChangesAsync();
 133        }
 134        catch (Exception ex)
 135        {
 136            _logger.LogWarning(ex, "Armtek ETP: не удалось записать итог запуска {RunId}", runId);
 137        }
 138    }
 139
 140    private async Task<ArmtekEtpSyncResult> RunAsync(DateOnly periodFrom, DateOnly periodTo, bool includeSettlements, Ca
 141    {
 142        // Сначала взаиморасчёты: фактуры по их документам дочитываются этим же запуском (связь документ ↔ фактура).
 143        string? settlementsError = null;
 144        (int Count, int Created) settlements = (0, 0);
 145        if (includeSettlements)
 146        try
 147        {
 148            await _client.LoginAsync(ct);
 149            var rows = await _client.GetSettlementsAsync(periodFrom, periodTo, ct);
 150            var (saved, created) = await UpsertSettlementsAsync(rows, ct);
 151            settlements = (saved.Count, created);
 152        }
 153        catch (Exception ex) when (ex is not OperationCanceledException)
 154        {
 155            settlementsError = ex.Message;
 156            _logger.LogWarning(ex, "Armtek ETP: взаиморасчёты за {From}–{To} не получены", periodFrom, periodTo);
 157        }
 158
 159        Exception? apiError = null;
 160        (int Loaded, int Errors, int Remaining) invoices = (0, 0, 0);
 161        try
 162        {
 163            invoices = await LoadInvoicesFromApiAsync(periodFrom, periodTo, ct);
 164        }
 165        catch (Exception ex) when (ex is not OperationCanceledException)
 166        {
 167            apiError = ex;
 168            _logger.LogWarning(ex, "Armtek API: фактуры за {From}–{To} не загружены", periodFrom, periodTo);
 169        }
 170
 171        var result = new ArmtekEtpSyncResult(settlements.Count, settlements.Created, invoices.Loaded, invoices.Errors, s
 172            invoices.Remaining, SettlementsSkipped: !includeSettlements);
 173        _logger.LogInformation("Armtek {From:dd.MM.yyyy}–{To:dd.MM.yyyy}: {Result}", periodFrom.ToDateTime(TimeOnly.MinV
 174            periodTo.ToDateTime(TimeOnly.MinValue), result);
 175        if (apiError != null)
 176            throw new ArmtekSyncPartialException("Фактуры по API не загружены: " + apiError.Message
 177                + (settlementsError == null ? $"; взаиморасчётов из кабинета: {settlements.Count}" : "; взаиморасчёты то
 178                result, apiError);
 179        return result;
 180    }
 181
 182    /// <summary>
 183    /// Фактуры за период через API: getOrderReportByDate даёт заказы с номерами фактур (продажи и возвраты), по каждой
 184    /// новой или недавней фактуре — getInvoice. Названия партнёров (покупатель, пункт разгрузки, адрес, контакт) API
 185    /// отдаёт кодами — они расшифровываются одним getUserInfo.
 186    /// </summary>
 187    private async Task<(int Loaded, int Errors, int Remaining)> LoadInvoicesFromApiAsync(DateOnly periodFrom, DateOnly p
 188    {
 189        var kunrg = await ResolvePayerAsync(ct);
 190        // Отчёт фильтрует по дате создания заказа, а фактура выставляется позже (дальние склады — недели спустя):
 191        // без запаса назад фактура периода с заказом, созданным до его начала, не попадает в отчёт.
 192        var reportFrom = periodFrom.AddDays(-Math.Max(0, _config.OrderReportExtraDays));
 193        var report = await _api.GetOrderReportByDateAsync(kunrg,
 194            createdFrom: reportFrom.ToDateTime(TimeOnly.MinValue), createdTo: periodTo.ToDateTime(TimeOnly.MinValue), ct
 195        if (report.HasErrors)
 196            throw new ArmtekEtpException("Armtek API, отчёт по заказам: " + string.Join("; ", report.Messages.Select(m =
 197
 198        var ordersByInvoice = (report.Resp?.Data ?? [])
 199            .Where(o => !string.IsNullOrWhiteSpace(o.Invoice))
 200            .GroupBy(o => o.Invoice.Trim())
 201            .ToDictionary(g => g.Key, g => g.ToList());
 202
 203        // Связь со взаиморасчётами: каждая фактура и кредит возврата из них (загруженных из кабинета или из Excel)
 204        // дочитывается через API, даже если её заказа нет в отчёте (заказ создан задолго до периода).
 205        var fromDate = periodFrom.ToDateTime(TimeOnly.MinValue);
 206        var toDate = periodTo.ToDateTime(TimeOnly.MinValue);
 207        var settlementDocs = await _context.ArmtekSettlements.AsNoTracking()
 208            .Where(x => x.Kunnr == kunrg && x.DocDate >= fromDate && x.DocDate <= toDate)
 209            .Select(x => new { x.Vbeln, x.DocType, x.DocTypeName, x.DocDate })
 210            .ToListAsync(ct);
 211        var docDates = new Dictionary<string, DateTime?>();
 212        foreach (var d in settlementDocs.Where(d => IsInvoiceDocument(d.DocType, d.DocTypeName)))
 213            docDates[d.Vbeln] = d.DocDate;
 214
 215        var orders = ordersByInvoice.Keys.Union(docDates.Keys)
 216            .Select(v => (Vbeln: v, Orders: ordersByInvoice.TryGetValue(v, out var o) ? o : new List<ArmtekOrderReportIt
 217                Date: docDates.TryGetValue(v, out var dd) ? dd : null))
 218            .ToList();
 219
 220        var refreshFrom = DateTime.Today.AddDays(-Math.Max(0, _config.InvoiceRefreshDays));
 221        var vbelns = orders.Select(g => g.Vbeln).ToList();
 222        var known = await _context.ArmtekInvoices.AsNoTracking()
 223            .Where(x => x.Kunnr == kunrg && vbelns.Contains(x.Vbeln))
 224            .Select(x => new { x.Vbeln, x.Source, x.InvoiceDate })
 225            .ToListAsync(ct);
 226        var knownByVbeln = known.ToDictionary(x => x.Vbeln);
 227
 228        // Новые, ещё не прочитанные через API (страницы кабинета дополняются API-полями) и свежие — у них меняются возв
 229        var pending = orders.Where(g => !knownByVbeln.TryGetValue(g.Vbeln, out var k)
 230                || k.Source != ApiSource || k.InvoiceDate == null || k.InvoiceDate >= refreshFrom)
 231            .OrderByDescending(g => g.Date ?? DateTime.MaxValue).ThenByDescending(g => g.Vbeln)
 232            .ToList();
 233        if (pending.Count == 0)
 234            return (0, 0, 0);
 235
 236        // У API суточная квота на логин (её же тратит проценка): за запуск — не больше InvoiceLoadLimit фактур,
 237        // самые новые первыми; остальные дочитают следующие запуски.
 238        var limit = Math.Max(1, _config.InvoiceLoadLimit);
 239        var toLoad = pending.Take(limit).ToList();
 240
 241        var parties = await LoadPartiesAsync(kunrg, ct);
 242        int loaded = 0, errors = 0;
 243        foreach (var group in toLoad)
 244        {
 245            ct.ThrowIfCancellationRequested();
 246            try
 247            {
 248                var response = await _api.GetInvoiceAsync(kunrg, group.Vbeln, ct: ct);
 249                if (response.Messages.Any(m => ArmtekDailyQuotaGuard.IsExceeded(m.Text)))
 250                {
 251                    _logger.LogWarning("Armtek API: суточная квота исчерпана, фактуры дочитаются следующим запуском");
 252                    break;
 253                }
 254                if (response.HasErrors || response.Resp == null)
 255                    throw new ArmtekEtpException(string.Join("; ", response.Messages.Select(m => m.Text)));
 256                await SaveApiInvoiceAsync(kunrg, group.Vbeln, response.Resp, group.Orders, parties, ct);
 257                loaded++;
 258            }
 259            catch (Exception ex) when (ex is not OperationCanceledException)
 260            {
 261                errors++;
 262                _logger.LogWarning(ex, "Armtek API: фактура {Kunrg}/{Invoice} не загружена", kunrg, group.Vbeln);
 263            }
 264        }
 265        return (loaded, errors, pending.Count - loaded - errors);
 266    }
 267
 268    /// <summary>Плательщик из настроек, иначе — плательщик по умолчанию из getUserInfo.</summary>
 269    private async Task<string> ResolvePayerAsync(CancellationToken ct)
 270    {
 271        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 272            return _config.KunnrRg.Trim();
 273        var info = await _api.GetUserInfoAsync(ct: ct);
 274        var rg = info.Resp?.Structure.RgTab.OrderByDescending(x => x.Default).FirstOrDefault()?.Kunnr;
 275        return !string.IsNullOrWhiteSpace(rg)
 276            ? rg.Trim()
 277            : throw new ArmtekEtpException("Armtek API: не задан плательщик (ArmtekEtp:KunnrRg) и getUserInfo его не вер
 278    }
 279
 280    private async Task<ArmtekBuyerItem?> LoadPartiesAsync(string kunrg, CancellationToken ct)
 281    {
 282        try
 283        {
 284            var info = await _api.GetUserInfoAsync(ct: ct);
 285            return info.Resp?.Structure.RgTab.FirstOrDefault(x => SameCode(x.Kunnr, kunrg));
 286        }
 287        catch (Exception ex) when (ex is not OperationCanceledException)
 288        {
 289            _logger.LogWarning(ex, "Armtek API: getUserInfo не получен — партнёры фактур останутся кодами");
 290            return null;
 291        }
 292    }
 293
 294    /// <summary>Загрузка (перечитывание) одной фактуры по запросу со страницы — через API.</summary>
 295    public async Task LoadInvoiceAsync(string kunnr, string vbeln, CancellationToken ct = default)
 296    {
 297        var response = await _api.GetInvoiceAsync(kunnr, vbeln, ct: ct);
 298        if (response.HasErrors || response.Resp == null)
 299            throw new ArmtekEtpException("Armtek API: " + string.Join("; ", response.Messages.Select(m => m.Text)));
 300
 301        // Заказ и причина возврата — из отчёта по заказам вокруг даты фактуры.
 302        var date = ParseDate(response.Resp.Header.InvoiceDate, "yyyyMMdd") ?? DateTime.Today;
 303        var report = await _api.GetOrderReportByDateAsync(kunnr, createdFrom: date.AddDays(-60), createdTo: date, ct: ct
 304        var orders = (report.Resp?.Data ?? []).Where(o => o.Invoice.Trim() == vbeln).ToList();
 305        await SaveApiInvoiceAsync(kunnr, vbeln, response.Resp, orders, await LoadPartiesAsync(kunnr, ct), ct);
 306    }
 307
 308    public const string ApiSource = "api";
 309
 310    private async Task SaveApiInvoiceAsync(string kunrg, string vbeln, ArmtekGetInvoiceResponse resp,
 311        IReadOnlyList<ArmtekOrderReportItem> orders, ArmtekBuyerItem? parties, CancellationToken ct)
 312    {
 313        var invoice = await _context.ArmtekInvoices
 314            .Include(x => x.Items)
 315            .SingleOrDefaultAsync(x => x.Kunnr == kunrg && x.Vbeln == vbeln, ct);
 316        if (invoice == null)
 317        {
 318            invoice = new ArmtekInvoice { Kunnr = kunrg, Vbeln = vbeln };
 319            _context.ArmtekInvoices.Add(invoice);
 320        }
 321
 322        // Поля, которых нет в API («Создал», «Статус доставки (ММВ)», товарная группа), остаются от страницы кабинета.
 323        var etpNames = invoice.Items.Where(x => x.ArmtekCode != null)
 324            .GroupBy(x => (x.Position, x.ArmtekCode))
 325            .ToDictionary(g => g.Key, g => g.First().Name);
 326        _context.ArmtekInvoiceItems.RemoveRange(invoice.Items);
 327        invoice.Items.Clear();
 328
 329        MapApiInvoice(invoice, resp, orders, parties, etpNames);
 330        invoice.LoadedAtUtc = DateTime.UtcNow;
 331        await _context.SaveChangesAsync(ct);
 332        _context.ChangeTracker.Clear();
 333    }
 334
 335    internal static void MapApiInvoice(ArmtekInvoice inv, ArmtekGetInvoiceResponse resp, IReadOnlyList<ArmtekOrderReport
 336        ArmtekBuyerItem? parties, IReadOnlyDictionary<(int, string?), string?>? etpNames = null)
 337    {
 338        var h = resp.Header;
 339        inv.Source = ApiSource;
 340        inv.InvoiceDate = ParseDate(h.InvoiceDate, "yyyyMMdd");
 341        inv.CreatedAt = ParseDate(h.CreatedAt, "yyyyMMddHHmmss");
 342        inv.BuyerCode = Cut(inv.Kunnr, 20);
 343        inv.BuyerName = Cut(parties?.ShortName is { Length: > 0 } sn ? sn : parties?.FullName, 500) ?? inv.BuyerName;
 344        inv.UnloadingPointCode = Cut(NullIfEmpty(h.Kunwe), 20);
 345        inv.UnloadingPointName = Cut(PartyName(parties?.WeTab, h.Kunwe), 500) ?? inv.UnloadingPointName;
 346        inv.DeliveryAddressCode = Cut(NullIfEmpty(h.Kunza), 20);
 347        inv.DeliveryAddress = Cut(PartyAddress(parties?.ZaTab, h.Kunza), 500) ?? inv.DeliveryAddress;
 348        inv.ContactCode = Cut(NullIfEmpty(h.ContactCode), 20);
 349        inv.Contact = Cut(ContactName(parties?.ContactTab, h.ContactCode), 500) ?? inv.Contact;
 350        inv.ReceiveMethod = Cut(NullIfEmpty(h.IncotermsText), 100);
 351        inv.UpdNumber = Cut(NullIfEmpty(h.Upd), 50);
 352        inv.TtnNumber = Cut(NullIfEmpty(h.Torg12), 50);
 353        inv.Payment = Cut(NullIfEmpty(h.Payment), 50);
 354        inv.PickupPointName = Cut(NullIfEmpty(h.PickupPointName), 255);
 355        inv.Contract = Cut(NullIfEmpty(h.ContractInfo), 100);
 356        inv.Currency = Cut(NullIfEmpty(h.Currency), 5);
 357        // MAX_RET_SUM без REFLAG=1 приходит одинаковым для всех фактур (не сумма этой фактуры) — не сохраняем.
 358        inv.MaxReturnSum = null;
 359        inv.OrderNumber = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.Order.TrimStart('0')).Where(o => o.Leng
 360        inv.OrderType = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderType).Where(o => o.Length > 0).Disti
 361        inv.OrderStatus = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderStatus).Where(o => o.Length > 0).D
 362        // COMMENT заказа — причина только у возвратов; у продаж там наш комментарий (метка «Studio2 IDs»).
 363        inv.ReturnReason = Cut(NullIfEmpty(string.Join("; ", orders
 364            .Where(o => o.OrderType.Contains("Возврат", StringComparison.OrdinalIgnoreCase))
 365            .Select(o => o.Comment).Where(o => o.Length > 0).Distinct())), 500);
 366
 367        foreach (var r in resp.Items)
 368        {
 369            var position = int.TryParse(r.Posnr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) ? p : 0;
 370            var etpName = etpNames != null && etpNames.TryGetValue((position, NullIfEmpty(r.Matnr)), out var n) ? n : nu
 371            inv.Items.Add(new ArmtekInvoiceItem
 372            {
 373                Position = position,
 374                ArmtekCode = Cut(NullIfEmpty(r.Matnr), 50),
 375                Article = Cut(NullIfEmpty(r.Pin), 100),
 376                Brand = Cut(NullIfEmpty(r.Brand), 100),
 377                Name = Cut(etpName ?? NullIfEmpty(r.Name), 500),
 378                FullName = Cut(NullIfEmpty(r.Name), 1000),
 379                Price = Dec(r.Price) ?? 0,
 380                Quantity = Dec(r.Amount) ?? 0,
 381                Returned = Dec(r.Retmg) ?? 0,
 382                Amount = Dec(r.Summa) ?? 0,
 383                OrderNumber = Cut(NullIfEmpty(r.Order.TrimStart('0')), 20),
 384                OrderPosition = Cut(NullIfEmpty(r.OrderPosition), 10),
 385                Note = Cut(NullIfEmpty(r.OrderNote), 500),
 386                Gtd = Cut(NullIfEmpty(r.Gtd), 100),
 387                Country = Cut(NullIfEmpty(r.Country), 100),
 388                NoReturn = r.NoReturn == "X",
 389                ReturnDays = int.TryParse(r.ReturnDays, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) ?
 390                MaxReturnDate = inv.InvoiceDate is { } invDate && int.TryParse(r.ReturnDays, out var days) && days > 0 ?
 391                Weight = Dec(r.Weight),
 392                Volume = Dec(r.Volume),
 393                VatRate = Dec(r.VatRate),
 394                VatAmount = Dec(r.VatAmount),
 395                Ean = Cut(NullIfEmpty(r.Ean), 50),
 396                Tnved = Cut(NullIfEmpty(r.Tnved), 20),
 397                RawJson = Newtonsoft.Json.JsonConvert.SerializeObject(r)
 398            });
 399        }
 400
 401        inv.TotalAmount = Dec(h.Summa) ?? inv.Items.Sum(x => x.Amount);
 402        inv.TotalQuantity = inv.Items.Sum(x => x.Quantity);
 403        inv.ReturnedQuantity = inv.Items.Sum(x => x.Returned);
 404        inv.HeaderJson = JsonSerializer.Serialize(BuildApiHeader(inv), JsonOptions);
 405        inv.ColumnsJson = "[]";
 406        inv.ItemsJson = Newtonsoft.Json.JsonConvert.SerializeObject(new { header = h, items = resp.Items, orders });
 407    }
 408
 409    /// <summary>Строки шапки для страницы фактуры — в том же виде, что и у страниц кабинета («название / код / значение
 410    private static List<ArmtekEtpHeaderField> BuildApiHeader(ArmtekInvoice inv)
 411    {
 412        var rows = new List<ArmtekEtpHeaderField>();
 413        void Add(string label, string? value, string? code = null)
 414        {
 415            if (string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(code)) return;
 416            var text = string.IsNullOrWhiteSpace(code) ? value! : (code + " " + value).Trim();
 417            rows.Add(new ArmtekEtpHeaderField(label, NullIfEmpty(code), text, null));
 418        }
 419        Add("Дата фактуры", inv.InvoiceDate?.ToString("dd.MM.yyyy"));
 420        Add("Дата и время создания", inv.CreatedAt?.ToString("dd.MM.yyyy HH:mm:ss"));
 421        Add("Создал", inv.CreatedBy);
 422        Add("Заказ", inv.OrderNumber);
 423        Add("Тип заказа", inv.OrderType);
 424        Add("Статус заказа", inv.OrderStatus);
 425        Add("Причина возврата", inv.ReturnReason);
 426        Add("Покупатель", inv.BuyerName, inv.BuyerCode);
 427        Add("Пункт разгрузки", inv.UnloadingPointName, inv.UnloadingPointCode);
 428        Add("Адрес доставки", inv.DeliveryAddress, inv.DeliveryAddressCode);
 429        Add("Контактное лицо", inv.Contact, inv.ContactCode);
 430        Add("Способ получения", inv.ReceiveMethod);
 431        Add("Пункт выдачи", inv.PickupPointName);
 432        Add("Статус доставки (ММВ)", inv.DeliveryStatus);
 433        Add("Оплата", inv.Payment);
 434        Add("Договор", inv.Contract);
 435        Add("УПД/Товчек", inv.UpdNumber);
 436        Add("ТН/ТТН", inv.TtnNumber);
 437        Add("Можно вернуть на сумму", inv.MaxReturnSum?.ToString("N2", CultureInfo.GetCultureInfo("ru-RU")) is { } m ? m
 438        return rows;
 439    }
 440
 441    private static string? PartyName(IEnumerable<ArmtekPartyItem>? list, string? code)
 442    {
 443        var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code));
 444        return p == null ? null : NullIfEmpty(p.FullName) ?? NullIfEmpty(p.ShortName);
 445    }
 446
 447    private static string? PartyAddress(IEnumerable<ArmtekPartyItem>? list, string? code)
 448    {
 449        var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code));
 450        return p == null ? null : NullIfEmpty(p.Address) ?? NullIfEmpty(p.FullName) ?? NullIfEmpty(p.ShortName);
 451    }
 452
 453    private static string? ContactName(IEnumerable<ArmtekContactItem>? list, string? code)
 454    {
 455        var c = list?.FirstOrDefault(x => SameCode(x.Parnr, code));
 456        return c == null ? null : NullIfEmpty(string.Join(" ", new[] { c.LastName, c.FirstName, c.MiddleName, c.Phone }.
 457    }
 458
 459    /// <summary>Коды SAP приходят то с ведущими нулями (KUNWE "0043174495"), то без (KUNNR в getUserInfo).</summary>
 460    private static bool SameCode(string? a, string? b) =>
 461        !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && a.Trim().TrimStart('0') == b.Trim().TrimStart(
 462
 463    private static decimal? Dec(string? s) =>
 464        decimal.TryParse(s?.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null;
 465
 466    private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
 467
 468    /// <summary>
 469    /// Импорт строк файла «Взаиморасчёты» (уже проверенных <see cref="ArmtekSettlementsExcelReader"/>): upsert по
 470    /// (Kunnr, Vbeln) и запись в журнал запусков — для «Обновлено» это такая же загрузка взаиморасчётов, как из кабинет
 471    /// Поля, которых в файле нет (код вида документа, направление, балансовая единица), у существующих строк сохраняютс
 472    /// </summary>
 473    public async Task<ArmtekExcelImportResult> ImportSettlementsAsync(IReadOnlyList<ArmtekSettlementExcelRow> rows, stri
 474        string? user, CancellationToken ct = default)
 475    {
 476        if (rows.Count == 0)
 477            throw new ArgumentException("Нет строк для импорта.", nameof(rows));
 478
 479        var now = DateTime.UtcNow;
 480        var run = new ArmtekEtpSyncRun
 481        {
 482            StartedAtUtc = now,
 483            PeriodFrom = rows.Min(r => r.DocDate),
 484            PeriodTo = rows.Max(r => r.DocDate),
 485            Trigger = Cut($"Excel «{fileName}»{(string.IsNullOrWhiteSpace(user) ? "" : " — " + user)}", 256)!
 486        };
 487        _context.ArmtekEtpSyncRuns.Add(run);
 488        await _context.SaveChangesAsync(ct);
 489
 490        var vbelns = rows.Select(r => r.Vbeln).Distinct().ToList();
 491        var existing = (await _context.ArmtekSettlements.Where(x => vbelns.Contains(x.Vbeln)).ToListAsync(ct))
 492            .ToDictionary(x => (x.Kunnr, x.Vbeln));
 493        int created = 0, updated = 0;
 494        foreach (var r in rows)
 495        {
 496            if (!existing.TryGetValue((r.Kunnr, r.Vbeln), out var e))
 497            {
 498                e = new ArmtekSettlement { Kunnr = r.Kunnr, Vbeln = r.Vbeln, FirstSeenAtUtc = now };
 499                _context.ArmtekSettlements.Add(e);
 500                existing[(r.Kunnr, r.Vbeln)] = e;
 501                created++;
 502            }
 503            else
 504            {
 505                updated++;
 506            }
 507
 508            e.ClientName = Cut(r.ClientName, 255) ?? e.ClientName;
 509            e.DocDate = r.DocDate;
 510            e.DocTypeName = Cut(r.DocTypeName, 255);
 511            e.DocType ??= DocTypeByName(r.DocTypeName);
 512            e.PrintNumber = Cut(r.DocNumber, 50) ?? e.PrintNumber;
 513            e.DocNumber ??= Cut(r.DocNumber, 50);
 514            e.PaymentMethodName = Cut(r.PaymentMethodName, 255) ?? e.PaymentMethodName;
 515            e.Currency = Cut(r.Currency, 5) ?? e.Currency;
 516            e.Amount = r.Amount;
 517            e.ReturnAmount = r.ReturnAmount;
 518            e.PaymentAmount = r.PaymentAmount;
 519            e.Balance = r.Balance;
 520            e.PayUntil = r.PayUntil ?? e.PayUntil;
 521            e.Contract = Cut(r.Contract, 100) ?? e.Contract;
 522            e.RawJson = JsonSerializer.Serialize(new { source = "excel", file = fileName, row = r.RowNumber, values = r.
 523            e.UpdatedAtUtc = now;
 524        }
 525        await _context.SaveChangesAsync(ct);
 526
 527        var result = new ArmtekExcelImportResult(rows.Count, created, updated, run.PeriodFrom, run.PeriodTo);
 528        _context.ChangeTracker.Clear();
 529        var saved = await _context.ArmtekEtpSyncRuns.FindAsync([run.Id], ct);
 530        if (saved != null)
 531        {
 532            saved.FinishedAtUtc = DateTime.UtcNow;
 533            saved.Success = true;
 534            saved.Message = Cut(result.ToString(), 2000);
 535            saved.Settlements = rows.Count;
 536            saved.NewSettlements = created;
 537            await _context.SaveChangesAsync(ct);
 538        }
 539        _logger.LogInformation("Armtek: {Result}", result);
 540        return result;
 541    }
 542
 543    /// <summary>Код вида документа по названию из Excel (в файле только название): фактура — ZFW, кредит возврата — RE.
 544    internal static string? DocTypeByName(string? name)
 545    {
 546        if (string.IsNullOrWhiteSpace(name) || name.Contains("Сторно", StringComparison.OrdinalIgnoreCase))
 547            return null;
 548        if (name.Contains("возврат", StringComparison.OrdinalIgnoreCase)) return "RE";
 549        if (name.Contains("фактур", StringComparison.OrdinalIgnoreCase)) return "ZFW";
 550        return null;
 551    }
 552
 553    /// <summary>У документа есть фактура в Armtek (getInvoice): виды из кода сайта, а без кода — по названию.</summary>
 554    internal static bool IsInvoiceDocument(string? docType, string? docTypeName) =>
 555        docType != null ? InvoiceDocTypes.Contains(docType) : DocTypeByName(docTypeName) != null;
 556
 557    private async Task<(List<ArmtekSettlement> Rows, int Created)> UpsertSettlementsAsync(
 558        IReadOnlyList<JsonElement> rows, CancellationToken ct)
 559    {
 560        var now = DateTime.UtcNow;
 561        var parsed = rows
 562            .Select(r => (Kunnr: ArmtekEtpParser.Str(r, "KUNNR"), Vbeln: ArmtekEtpParser.Str(r, "VBELN"), Row: r))
 563            .Where(x => x.Kunnr != null && x.Vbeln != null)
 564            .GroupBy(x => (x.Kunnr!, x.Vbeln!))
 565            .Select(g => g.Last())
 566            .ToList();
 567
 568        var vbelns = parsed.Select(x => x.Vbeln!).Distinct().ToList();
 569        var existing = await _context.ArmtekSettlements
 570            .Where(x => vbelns.Contains(x.Vbeln))
 571            .ToListAsync(ct);
 572        var byKey = existing.ToDictionary(x => (x.Kunnr, x.Vbeln));
 573
 574        var result = new List<ArmtekSettlement>(parsed.Count);
 575        var created = 0;
 576        foreach (var (kunnr, vbeln, row) in parsed)
 577        {
 578            if (!byKey.TryGetValue((kunnr!, vbeln!), out var entity))
 579            {
 580                entity = new ArmtekSettlement { Kunnr = kunnr!, Vbeln = vbeln!, FirstSeenAtUtc = now };
 581                _context.ArmtekSettlements.Add(entity);
 582                created++;
 583            }
 584            MapSettlement(entity, row);
 585            entity.UpdatedAtUtc = now;
 586            result.Add(entity);
 587        }
 588
 589        await _context.SaveChangesAsync(ct);
 590        return (result, created);
 591    }
 592
 593    internal static void MapSettlement(ArmtekSettlement e, JsonElement r)
 594    {
 595        e.ClientName = Cut(ArmtekEtpParser.Str(r, "NAME"), 255);
 596        e.DocDate = ArmtekEtpParser.SapDate(r, "DOCDATE");
 597        e.DocType = Cut(ArmtekEtpParser.Str(r, "FKART"), 10);
 598        e.DocTypeName = Cut(ArmtekEtpParser.Str(r, "FKART_NAME"), 255);
 599        e.DocNumber = Cut(ArmtekEtpParser.Str(r, "DOCNUM"), 50);
 600        e.PrintNumber = Cut(ArmtekEtpParser.Str(r, "ZZSF"), 50);
 601        e.PaymentMethod = Cut(ArmtekEtpParser.Str(r, "ZWELS"), 10);
 602        e.PaymentMethodName = Cut(ArmtekEtpParser.Str(r, "ZWELS_NAME"), 255);
 603        e.Amount = ArmtekEtpParser.Dec(r, "NETWR") ?? 0;
 604        e.ReturnAmount = ArmtekEtpParser.Dec(r, "NETWR_RE") ?? 0;
 605        e.PaymentAmount = ArmtekEtpParser.Dec(r, "NETWR_PAY") ?? 0;
 606        e.RemainderAmount = ArmtekEtpParser.Dec(r, "NETWR_REM") ?? 0;
 607        e.RemainderDate = ArmtekEtpParser.SapDate(r, "REMDATE");
 608        e.PayUntil = ArmtekEtpParser.SapDate(r, "PAYDATE");
 609        e.Balance = ArmtekEtpParser.Dec(r, "SKFOR");
 610        e.Currency = Cut(ArmtekEtpParser.Str(r, "WAERK"), 5);
 611        e.Contract = Cut(ArmtekEtpParser.Str(r, "BSTKD"), 100);
 612        e.Division = Cut(ArmtekEtpParser.Str(r, "SPART"), 10);
 613        e.DivisionName = Cut(ArmtekEtpParser.Str(r, "SPART_NAME"), 255);
 614        e.CompanyCode = Cut(ArmtekEtpParser.Str(r, "BUKRS"), 10);
 615        e.CompanyName = Cut(ArmtekEtpParser.Str(r, "BUKRSN"), 255);
 616        e.RawJson = r.GetRawText();
 617    }
 618
 619    private static DateTime? ParseDate(string? s, string format) =>
 620        DateTime.TryParseExact(s?.Trim(), format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : nu
 621
 622    private static string? Cut(string? s, int max) => s == null ? null : s.Length <= max ? s : s[..max];
 623}
 624
 625/// <summary>Фактуры по API не загрузились; результат — то, что успело загрузиться (взаиморасчёты из кабинета).</summary
 626public sealed class ArmtekSyncPartialException(string message, ArmtekEtpSyncResult result, Exception inner) : Exception(
 627{
 628    public ArmtekEtpSyncResult Result { get; } = result;
 629}

Methods/Properties

get_Rows()
ToString()