< Summary

Information
Class: Services.ArmtekEtpServices.ArmtekEtpSyncService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ArmtekEtpServices/ArmtekEtpSyncService.cs
Line coverage
91%
Covered lines: 359
Uncovered lines: 33
Coverable lines: 392
Total lines: 629
Line coverage: 91.5%
Branch coverage
66%
Covered branches: 126
Total branches: 190
Branch coverage: 66.3%
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%
SyncAsync()50%4483.33%
FinishRunAsync()75%4483.33%
RunAsync()83.33%66100%
LoadInvoicesFromApiAsync()65%222084.09%
ResolvePayerAsync()12.5%20842.85%
LoadPartiesAsync()50%3240%
LoadInvoiceAsync()0%110100%
SaveApiInvoiceAsync()100%22100%
MapApiInvoice(...)59.52%4242100%
BuildApiHeader(...)50%88100%
Add()100%66100%
PartyName(...)50%66100%
PartyAddress(...)50%88100%
ContactName(...)50%44100%
SameCode(...)50%44100%
Dec(...)50%44100%
NullIfEmpty(...)100%22100%
ImportSettlementsAsync()76.92%262697.95%
DocTypeByName(...)100%88100%
IsInvoiceDocument(...)100%22100%
UpsertSettlementsAsync()100%44100%
MapSettlement(...)100%11100%
ParseDate(...)50%44100%
Cut(...)75%44100%

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>
 25public sealed record ArmtekExcelImportResult(int Rows, int Created, int Updated, DateTime PeriodFrom, DateTime PeriodTo)
 26{
 27    public override string ToString() =>
 28        $"Взаиморасчёты из 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>
 144    public static readonly IReadOnlySet<string> InvoiceDocTypes = new HashSet<string>(StringComparer.Ordinal)
 145    {
 146        "ZROZ", "ZROS", "ZWRE", "ZRED", "ZRW", "ZCRE", "ZF2", "RE", "ZFSE", "ZFW", "ZFB", "ZPLE", "ZPLI",
 147        "ZFKE", "ZFKX", "ZFKW", "ZFWX", "ZSRE"
 148    };
 49
 150    private static readonly JsonSerializerOptions JsonOptions = new()
 151    {
 152        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
 153    };
 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
 861    public ArmtekEtpSyncService(ApplicationDbContext context, ArmtekEtpClient client, ArmtekByDataManager api,
 862        ArmtekEtpConfig config, ILogger<ArmtekEtpSyncService> logger)
 63    {
 864        _context = context;
 865        _client = client;
 866        _api = api;
 867        _config = config;
 868        _logger = logger;
 869    }
 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    {
 780        var today = DateOnly.FromDateTime(DateTime.Today);
 781        var periodTo = to ?? today;
 782        var periodFrom = from ?? periodTo.AddDays(-Math.Max(1, _config.LookbackDays));
 83
 784        var run = new ArmtekEtpSyncRun
 785        {
 786            StartedAtUtc = DateTime.UtcNow,
 787            PeriodFrom = periodFrom.ToDateTime(TimeOnly.MinValue),
 788            PeriodTo = periodTo.ToDateTime(TimeOnly.MinValue),
 789            Trigger = Cut(trigger, 256) ?? ScheduleTrigger
 790        };
 791        _context.ArmtekEtpSyncRuns.Add(run);
 792        await _context.SaveChangesAsync(ct);
 793        var runId = run.Id;
 94
 95        try
 96        {
 797            var result = await RunAsync(periodFrom, periodTo, includeSettlements, ct);
 698            await FinishRunAsync(runId, true, result.ToString(), result);
 699            return result;
 100        }
 1101        catch (ArmtekSyncPartialException partial)
 102        {
 1103            await FinishRunAsync(runId, false, partial.Message, partial.Result);
 1104            throw;
 0105        }
 0106        catch (Exception ex)
 107        {
 0108            await FinishRunAsync(runId, false, ex.Message, null);
 0109            throw;
 110        }
 6111    }
 112
 113    /// <summary>Итог запуска пишется отдельно от основной работы: даже после сбоя и при отменённом токене.</summary>
 114    private async Task FinishRunAsync(long runId, bool success, string message, ArmtekEtpSyncResult? result)
 115    {
 116        try
 117        {
 7118            _context.ChangeTracker.Clear();
 7119            var run = await _context.ArmtekEtpSyncRuns.FindAsync(runId);
 7120            if (run == null) return;
 7121            run.FinishedAtUtc = DateTime.UtcNow;
 7122            run.Success = success;
 7123            run.Message = Cut(message, 2000);
 7124            if (result != null)
 125            {
 7126                run.Settlements = result.Settlements;
 7127                run.NewSettlements = result.NewSettlements;
 7128                run.Invoices = result.Invoices;
 7129                run.InvoiceErrors = result.InvoiceErrors;
 7130                run.SettlementsError = Cut(result.SettlementsError, 2000);
 131            }
 7132            await _context.SaveChangesAsync();
 7133        }
 0134        catch (Exception ex)
 135        {
 0136            _logger.LogWarning(ex, "Armtek ETP: не удалось записать итог запуска {RunId}", runId);
 0137        }
 7138    }
 139
 140    private async Task<ArmtekEtpSyncResult> RunAsync(DateOnly periodFrom, DateOnly periodTo, bool includeSettlements, Ca
 141    {
 142        // Сначала взаиморасчёты: фактуры по их документам дочитываются этим же запуском (связь документ ↔ фактура).
 7143        string? settlementsError = null;
 7144        (int Count, int Created) settlements = (0, 0);
 7145        if (includeSettlements)
 146        try
 147        {
 4148            await _client.LoginAsync(ct);
 3149            var rows = await _client.GetSettlementsAsync(periodFrom, periodTo, ct);
 3150            var (saved, created) = await UpsertSettlementsAsync(rows, ct);
 3151            settlements = (saved.Count, created);
 3152        }
 1153        catch (Exception ex) when (ex is not OperationCanceledException)
 154        {
 1155            settlementsError = ex.Message;
 1156            _logger.LogWarning(ex, "Armtek ETP: взаиморасчёты за {From}–{To} не получены", periodFrom, periodTo);
 1157        }
 158
 7159        Exception? apiError = null;
 7160        (int Loaded, int Errors, int Remaining) invoices = (0, 0, 0);
 161        try
 162        {
 7163            invoices = await LoadInvoicesFromApiAsync(periodFrom, periodTo, ct);
 6164        }
 1165        catch (Exception ex) when (ex is not OperationCanceledException)
 166        {
 1167            apiError = ex;
 1168            _logger.LogWarning(ex, "Armtek API: фактуры за {From}–{To} не загружены", periodFrom, periodTo);
 1169        }
 170
 7171        var result = new ArmtekEtpSyncResult(settlements.Count, settlements.Created, invoices.Loaded, invoices.Errors, s
 7172            invoices.Remaining, SettlementsSkipped: !includeSettlements);
 7173        _logger.LogInformation("Armtek {From:dd.MM.yyyy}–{To:dd.MM.yyyy}: {Result}", periodFrom.ToDateTime(TimeOnly.MinV
 7174            periodTo.ToDateTime(TimeOnly.MinValue), result);
 7175        if (apiError != null)
 1176            throw new ArmtekSyncPartialException("Фактуры по API не загружены: " + apiError.Message
 1177                + (settlementsError == null ? $"; взаиморасчётов из кабинета: {settlements.Count}" : "; взаиморасчёты то
 1178                result, apiError);
 6179        return result;
 6180    }
 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    {
 7189        var kunrg = await ResolvePayerAsync(ct);
 190        // Отчёт фильтрует по дате создания заказа, а фактура выставляется позже (дальние склады — недели спустя):
 191        // без запаса назад фактура периода с заказом, созданным до его начала, не попадает в отчёт.
 7192        var reportFrom = periodFrom.AddDays(-Math.Max(0, _config.OrderReportExtraDays));
 7193        var report = await _api.GetOrderReportByDateAsync(kunrg,
 7194            createdFrom: reportFrom.ToDateTime(TimeOnly.MinValue), createdTo: periodTo.ToDateTime(TimeOnly.MinValue), ct
 6195        if (report.HasErrors)
 0196            throw new ArmtekEtpException("Armtek API, отчёт по заказам: " + string.Join("; ", report.Messages.Select(m =
 197
 6198        var ordersByInvoice = (report.Resp?.Data ?? [])
 18199            .Where(o => !string.IsNullOrWhiteSpace(o.Invoice))
 12200            .GroupBy(o => o.Invoice.Trim())
 30201            .ToDictionary(g => g.Key, g => g.ToList());
 202
 203        // Связь со взаиморасчётами: каждая фактура и кредит возврата из них (загруженных из кабинета или из Excel)
 204        // дочитывается через API, даже если её заказа нет в отчёте (заказ создан задолго до периода).
 6205        var fromDate = periodFrom.ToDateTime(TimeOnly.MinValue);
 6206        var toDate = periodTo.ToDateTime(TimeOnly.MinValue);
 6207        var settlementDocs = await _context.ArmtekSettlements.AsNoTracking()
 6208            .Where(x => x.Kunnr == kunrg && x.DocDate >= fromDate && x.DocDate <= toDate)
 6209            .Select(x => new { x.Vbeln, x.DocType, x.DocTypeName, x.DocDate })
 6210            .ToListAsync(ct);
 6211        var docDates = new Dictionary<string, DateTime?>();
 24212        foreach (var d in settlementDocs.Where(d => IsInvoiceDocument(d.DocType, d.DocTypeName)))
 3213            docDates[d.Vbeln] = d.DocDate;
 214
 6215        var orders = ordersByInvoice.Keys.Union(docDates.Keys)
 15216            .Select(v => (Vbeln: v, Orders: ordersByInvoice.TryGetValue(v, out var o) ? o : new List<ArmtekOrderReportIt
 15217                Date: docDates.TryGetValue(v, out var dd) ? dd : null))
 6218            .ToList();
 219
 6220        var refreshFrom = DateTime.Today.AddDays(-Math.Max(0, _config.InvoiceRefreshDays));
 21221        var vbelns = orders.Select(g => g.Vbeln).ToList();
 6222        var known = await _context.ArmtekInvoices.AsNoTracking()
 6223            .Where(x => x.Kunnr == kunrg && vbelns.Contains(x.Vbeln))
 6224            .Select(x => new { x.Vbeln, x.Source, x.InvoiceDate })
 6225            .ToListAsync(ct);
 9226        var knownByVbeln = known.ToDictionary(x => x.Vbeln);
 227
 228        // Новые, ещё не прочитанные через API (страницы кабинета дополняются API-полями) и свежие — у них меняются возв
 21229        var pending = orders.Where(g => !knownByVbeln.TryGetValue(g.Vbeln, out var k)
 21230                || k.Source != ApiSource || k.InvoiceDate == null || k.InvoiceDate >= refreshFrom)
 30231            .OrderByDescending(g => g.Date ?? DateTime.MaxValue).ThenByDescending(g => g.Vbeln)
 6232            .ToList();
 6233        if (pending.Count == 0)
 0234            return (0, 0, 0);
 235
 236        // У API суточная квота на логин (её же тратит проценка): за запуск — не больше InvoiceLoadLimit фактур,
 237        // самые новые первыми; остальные дочитают следующие запуски.
 6238        var limit = Math.Max(1, _config.InvoiceLoadLimit);
 6239        var toLoad = pending.Take(limit).ToList();
 240
 6241        var parties = await LoadPartiesAsync(kunrg, ct);
 12242        int loaded = 0, errors = 0;
 38243        foreach (var group in toLoad)
 244        {
 13245            ct.ThrowIfCancellationRequested();
 246            try
 247            {
 13248                var response = await _api.GetInvoiceAsync(kunrg, group.Vbeln, ct: ct);
 13249                if (response.Messages.Any(m => ArmtekDailyQuotaGuard.IsExceeded(m.Text)))
 250                {
 0251                    _logger.LogWarning("Armtek API: суточная квота исчерпана, фактуры дочитаются следующим запуском");
 0252                    break;
 253                }
 13254                if (response.HasErrors || response.Resp == null)
 0255                    throw new ArmtekEtpException(string.Join("; ", response.Messages.Select(m => m.Text)));
 13256                await SaveApiInvoiceAsync(kunrg, group.Vbeln, response.Resp, group.Orders, parties, ct);
 13257                loaded++;
 13258            }
 0259            catch (Exception ex) when (ex is not OperationCanceledException)
 260            {
 0261                errors++;
 0262                _logger.LogWarning(ex, "Armtek API: фактура {Kunrg}/{Invoice} не загружена", kunrg, group.Vbeln);
 0263            }
 13264        }
 6265        return (loaded, errors, pending.Count - loaded - errors);
 6266    }
 267
 268    /// <summary>Плательщик из настроек, иначе — плательщик по умолчанию из getUserInfo.</summary>
 269    private async Task<string> ResolvePayerAsync(CancellationToken ct)
 270    {
 7271        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 7272            return _config.KunnrRg.Trim();
 0273        var info = await _api.GetUserInfoAsync(ct: ct);
 0274        var rg = info.Resp?.Structure.RgTab.OrderByDescending(x => x.Default).FirstOrDefault()?.Kunnr;
 0275        return !string.IsNullOrWhiteSpace(rg)
 0276            ? rg.Trim()
 0277            : throw new ArmtekEtpException("Armtek API: не задан плательщик (ArmtekEtp:KunnrRg) и getUserInfo его не вер
 7278    }
 279
 280    private async Task<ArmtekBuyerItem?> LoadPartiesAsync(string kunrg, CancellationToken ct)
 281    {
 282        try
 283        {
 6284            var info = await _api.GetUserInfoAsync(ct: ct);
 12285            return info.Resp?.Structure.RgTab.FirstOrDefault(x => SameCode(x.Kunnr, kunrg));
 286        }
 0287        catch (Exception ex) when (ex is not OperationCanceledException)
 288        {
 0289            _logger.LogWarning(ex, "Armtek API: getUserInfo не получен — партнёры фактур останутся кодами");
 0290            return null;
 291        }
 6292    }
 293
 294    /// <summary>Загрузка (перечитывание) одной фактуры по запросу со страницы — через API.</summary>
 295    public async Task LoadInvoiceAsync(string kunnr, string vbeln, CancellationToken ct = default)
 296    {
 0297        var response = await _api.GetInvoiceAsync(kunnr, vbeln, ct: ct);
 0298        if (response.HasErrors || response.Resp == null)
 0299            throw new ArmtekEtpException("Armtek API: " + string.Join("; ", response.Messages.Select(m => m.Text)));
 300
 301        // Заказ и причина возврата — из отчёта по заказам вокруг даты фактуры.
 0302        var date = ParseDate(response.Resp.Header.InvoiceDate, "yyyyMMdd") ?? DateTime.Today;
 0303        var report = await _api.GetOrderReportByDateAsync(kunnr, createdFrom: date.AddDays(-60), createdTo: date, ct: ct
 0304        var orders = (report.Resp?.Data ?? []).Where(o => o.Invoice.Trim() == vbeln).ToList();
 0305        await SaveApiInvoiceAsync(kunnr, vbeln, response.Resp, orders, await LoadPartiesAsync(kunnr, ct), ct);
 0306    }
 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    {
 13313        var invoice = await _context.ArmtekInvoices
 13314            .Include(x => x.Items)
 13315            .SingleOrDefaultAsync(x => x.Kunnr == kunrg && x.Vbeln == vbeln, ct);
 13316        if (invoice == null)
 317        {
 10318            invoice = new ArmtekInvoice { Kunnr = kunrg, Vbeln = vbeln };
 10319            _context.ArmtekInvoices.Add(invoice);
 320        }
 321
 322        // Поля, которых нет в API («Создал», «Статус доставки (ММВ)», товарная группа), остаются от страницы кабинета.
 16323        var etpNames = invoice.Items.Where(x => x.ArmtekCode != null)
 3324            .GroupBy(x => (x.Position, x.ArmtekCode))
 19325            .ToDictionary(g => g.Key, g => g.First().Name);
 13326        _context.ArmtekInvoiceItems.RemoveRange(invoice.Items);
 13327        invoice.Items.Clear();
 328
 13329        MapApiInvoice(invoice, resp, orders, parties, etpNames);
 13330        invoice.LoadedAtUtc = DateTime.UtcNow;
 13331        await _context.SaveChangesAsync(ct);
 13332        _context.ChangeTracker.Clear();
 13333    }
 334
 335    internal static void MapApiInvoice(ArmtekInvoice inv, ArmtekGetInvoiceResponse resp, IReadOnlyList<ArmtekOrderReport
 336        ArmtekBuyerItem? parties, IReadOnlyDictionary<(int, string?), string?>? etpNames = null)
 337    {
 13338        var h = resp.Header;
 13339        inv.Source = ApiSource;
 13340        inv.InvoiceDate = ParseDate(h.InvoiceDate, "yyyyMMdd");
 13341        inv.CreatedAt = ParseDate(h.CreatedAt, "yyyyMMddHHmmss");
 13342        inv.BuyerCode = Cut(inv.Kunnr, 20);
 13343        inv.BuyerName = Cut(parties?.ShortName is { Length: > 0 } sn ? sn : parties?.FullName, 500) ?? inv.BuyerName;
 13344        inv.UnloadingPointCode = Cut(NullIfEmpty(h.Kunwe), 20);
 13345        inv.UnloadingPointName = Cut(PartyName(parties?.WeTab, h.Kunwe), 500) ?? inv.UnloadingPointName;
 13346        inv.DeliveryAddressCode = Cut(NullIfEmpty(h.Kunza), 20);
 13347        inv.DeliveryAddress = Cut(PartyAddress(parties?.ZaTab, h.Kunza), 500) ?? inv.DeliveryAddress;
 13348        inv.ContactCode = Cut(NullIfEmpty(h.ContactCode), 20);
 13349        inv.Contact = Cut(ContactName(parties?.ContactTab, h.ContactCode), 500) ?? inv.Contact;
 13350        inv.ReceiveMethod = Cut(NullIfEmpty(h.IncotermsText), 100);
 13351        inv.UpdNumber = Cut(NullIfEmpty(h.Upd), 50);
 13352        inv.TtnNumber = Cut(NullIfEmpty(h.Torg12), 50);
 13353        inv.Payment = Cut(NullIfEmpty(h.Payment), 50);
 13354        inv.PickupPointName = Cut(NullIfEmpty(h.PickupPointName), 255);
 13355        inv.Contract = Cut(NullIfEmpty(h.ContractInfo), 100);
 13356        inv.Currency = Cut(NullIfEmpty(h.Currency), 5);
 357        // MAX_RET_SUM без REFLAG=1 приходит одинаковым для всех фактур (не сумма этой фактуры) — не сохраняем.
 13358        inv.MaxReturnSum = null;
 33359        inv.OrderNumber = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.Order.TrimStart('0')).Where(o => o.Leng
 33360        inv.OrderType = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderType).Where(o => o.Length > 0).Disti
 33361        inv.OrderStatus = Cut(NullIfEmpty(string.Join(", ", orders.Select(o => o.OrderStatus).Where(o => o.Length > 0).D
 362        // COMMENT заказа — причина только у возвратов; у продаж там наш комментарий (метка «Studio2 IDs»).
 13363        inv.ReturnReason = Cut(NullIfEmpty(string.Join("; ", orders
 10364            .Where(o => o.OrderType.Contains("Возврат", StringComparison.OrdinalIgnoreCase))
 21365            .Select(o => o.Comment).Where(o => o.Length > 0).Distinct())), 500);
 366
 52367        foreach (var r in resp.Items)
 368        {
 13369            var position = int.TryParse(r.Posnr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) ? p : 0;
 13370            var etpName = etpNames != null && etpNames.TryGetValue((position, NullIfEmpty(r.Matnr)), out var n) ? n : nu
 13371            inv.Items.Add(new ArmtekInvoiceItem
 13372            {
 13373                Position = position,
 13374                ArmtekCode = Cut(NullIfEmpty(r.Matnr), 50),
 13375                Article = Cut(NullIfEmpty(r.Pin), 100),
 13376                Brand = Cut(NullIfEmpty(r.Brand), 100),
 13377                Name = Cut(etpName ?? NullIfEmpty(r.Name), 500),
 13378                FullName = Cut(NullIfEmpty(r.Name), 1000),
 13379                Price = Dec(r.Price) ?? 0,
 13380                Quantity = Dec(r.Amount) ?? 0,
 13381                Returned = Dec(r.Retmg) ?? 0,
 13382                Amount = Dec(r.Summa) ?? 0,
 13383                OrderNumber = Cut(NullIfEmpty(r.Order.TrimStart('0')), 20),
 13384                OrderPosition = Cut(NullIfEmpty(r.OrderPosition), 10),
 13385                Note = Cut(NullIfEmpty(r.OrderNote), 500),
 13386                Gtd = Cut(NullIfEmpty(r.Gtd), 100),
 13387                Country = Cut(NullIfEmpty(r.Country), 100),
 13388                NoReturn = r.NoReturn == "X",
 13389                ReturnDays = int.TryParse(r.ReturnDays, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) ?
 13390                MaxReturnDate = inv.InvoiceDate is { } invDate && int.TryParse(r.ReturnDays, out var days) && days > 0 ?
 13391                Weight = Dec(r.Weight),
 13392                Volume = Dec(r.Volume),
 13393                VatRate = Dec(r.VatRate),
 13394                VatAmount = Dec(r.VatAmount),
 13395                Ean = Cut(NullIfEmpty(r.Ean), 50),
 13396                Tnved = Cut(NullIfEmpty(r.Tnved), 20),
 13397                RawJson = Newtonsoft.Json.JsonConvert.SerializeObject(r)
 13398            });
 399        }
 400
 13401        inv.TotalAmount = Dec(h.Summa) ?? inv.Items.Sum(x => x.Amount);
 26402        inv.TotalQuantity = inv.Items.Sum(x => x.Quantity);
 26403        inv.ReturnedQuantity = inv.Items.Sum(x => x.Returned);
 13404        inv.HeaderJson = JsonSerializer.Serialize(BuildApiHeader(inv), JsonOptions);
 13405        inv.ColumnsJson = "[]";
 13406        inv.ItemsJson = Newtonsoft.Json.JsonConvert.SerializeObject(new { header = h, items = resp.Items, orders });
 13407    }
 408
 409    /// <summary>Строки шапки для страницы фактуры — в том же виде, что и у страниц кабинета («название / код / значение
 410    private static List<ArmtekEtpHeaderField> BuildApiHeader(ArmtekInvoice inv)
 411    {
 13412        var rows = new List<ArmtekEtpHeaderField>();
 413        void Add(string label, string? value, string? code = null)
 414        {
 304415            if (string.IsNullOrWhiteSpace(value) && string.IsNullOrWhiteSpace(code)) return;
 190416            var text = string.IsNullOrWhiteSpace(code) ? value! : (code + " " + value).Trim();
 190417            rows.Add(new ArmtekEtpHeaderField(label, NullIfEmpty(code), text, null));
 190418        }
 13419        Add("Дата фактуры", inv.InvoiceDate?.ToString("dd.MM.yyyy"));
 13420        Add("Дата и время создания", inv.CreatedAt?.ToString("dd.MM.yyyy HH:mm:ss"));
 13421        Add("Создал", inv.CreatedBy);
 13422        Add("Заказ", inv.OrderNumber);
 13423        Add("Тип заказа", inv.OrderType);
 13424        Add("Статус заказа", inv.OrderStatus);
 13425        Add("Причина возврата", inv.ReturnReason);
 13426        Add("Покупатель", inv.BuyerName, inv.BuyerCode);
 13427        Add("Пункт разгрузки", inv.UnloadingPointName, inv.UnloadingPointCode);
 13428        Add("Адрес доставки", inv.DeliveryAddress, inv.DeliveryAddressCode);
 13429        Add("Контактное лицо", inv.Contact, inv.ContactCode);
 13430        Add("Способ получения", inv.ReceiveMethod);
 13431        Add("Пункт выдачи", inv.PickupPointName);
 13432        Add("Статус доставки (ММВ)", inv.DeliveryStatus);
 13433        Add("Оплата", inv.Payment);
 13434        Add("Договор", inv.Contract);
 13435        Add("УПД/Товчек", inv.UpdNumber);
 13436        Add("ТН/ТТН", inv.TtnNumber);
 13437        Add("Можно вернуть на сумму", inv.MaxReturnSum?.ToString("N2", CultureInfo.GetCultureInfo("ru-RU")) is { } m ? m
 13438        return rows;
 439    }
 440
 441    private static string? PartyName(IEnumerable<ArmtekPartyItem>? list, string? code)
 442    {
 26443        var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code));
 13444        return p == null ? null : NullIfEmpty(p.FullName) ?? NullIfEmpty(p.ShortName);
 445    }
 446
 447    private static string? PartyAddress(IEnumerable<ArmtekPartyItem>? list, string? code)
 448    {
 26449        var p = list?.FirstOrDefault(x => SameCode(x.Kunnr, code));
 13450        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    {
 26455        var c = list?.FirstOrDefault(x => SameCode(x.Parnr, code));
 65456        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) =>
 45461        !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && a.Trim().TrimStart('0') == b.Trim().TrimStart(
 462
 463    private static decimal? Dec(string? s) =>
 117464        decimal.TryParse(s?.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null;
 465
 577466    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    {
 1476        if (rows.Count == 0)
 0477            throw new ArgumentException("Нет строк для импорта.", nameof(rows));
 478
 1479        var now = DateTime.UtcNow;
 1480        var run = new ArmtekEtpSyncRun
 1481        {
 1482            StartedAtUtc = now,
 4483            PeriodFrom = rows.Min(r => r.DocDate),
 4484            PeriodTo = rows.Max(r => r.DocDate),
 1485            Trigger = Cut($"Excel «{fileName}»{(string.IsNullOrWhiteSpace(user) ? "" : " — " + user)}", 256)!
 1486        };
 1487        _context.ArmtekEtpSyncRuns.Add(run);
 1488        await _context.SaveChangesAsync(ct);
 489
 5490        var vbelns = rows.Select(r => r.Vbeln).Distinct().ToList();
 1491        var existing = (await _context.ArmtekSettlements.Where(x => vbelns.Contains(x.Vbeln)).ToListAsync(ct))
 2492            .ToDictionary(x => (x.Kunnr, x.Vbeln));
 2493        int created = 0, updated = 0;
 10494        foreach (var r in rows)
 495        {
 4496            if (!existing.TryGetValue((r.Kunnr, r.Vbeln), out var e))
 497            {
 3498                e = new ArmtekSettlement { Kunnr = r.Kunnr, Vbeln = r.Vbeln, FirstSeenAtUtc = now };
 3499                _context.ArmtekSettlements.Add(e);
 3500                existing[(r.Kunnr, r.Vbeln)] = e;
 3501                created++;
 502            }
 503            else
 504            {
 1505                updated++;
 506            }
 507
 4508            e.ClientName = Cut(r.ClientName, 255) ?? e.ClientName;
 4509            e.DocDate = r.DocDate;
 4510            e.DocTypeName = Cut(r.DocTypeName, 255);
 4511            e.DocType ??= DocTypeByName(r.DocTypeName);
 4512            e.PrintNumber = Cut(r.DocNumber, 50) ?? e.PrintNumber;
 4513            e.DocNumber ??= Cut(r.DocNumber, 50);
 4514            e.PaymentMethodName = Cut(r.PaymentMethodName, 255) ?? e.PaymentMethodName;
 4515            e.Currency = Cut(r.Currency, 5) ?? e.Currency;
 4516            e.Amount = r.Amount;
 4517            e.ReturnAmount = r.ReturnAmount;
 4518            e.PaymentAmount = r.PaymentAmount;
 4519            e.Balance = r.Balance;
 4520            e.PayUntil = r.PayUntil ?? e.PayUntil;
 4521            e.Contract = Cut(r.Contract, 100) ?? e.Contract;
 4522            e.RawJson = JsonSerializer.Serialize(new { source = "excel", file = fileName, row = r.RowNumber, values = r.
 4523            e.UpdatedAtUtc = now;
 524        }
 1525        await _context.SaveChangesAsync(ct);
 526
 1527        var result = new ArmtekExcelImportResult(rows.Count, created, updated, run.PeriodFrom, run.PeriodTo);
 1528        _context.ChangeTracker.Clear();
 1529        var saved = await _context.ArmtekEtpSyncRuns.FindAsync([run.Id], ct);
 1530        if (saved != null)
 531        {
 1532            saved.FinishedAtUtc = DateTime.UtcNow;
 1533            saved.Success = true;
 1534            saved.Message = Cut(result.ToString(), 2000);
 1535            saved.Settlements = rows.Count;
 1536            saved.NewSettlements = created;
 1537            await _context.SaveChangesAsync(ct);
 538        }
 1539        _logger.LogInformation("Armtek: {Result}", result);
 1540        return result;
 1541    }
 542
 543    /// <summary>Код вида документа по названию из Excel (в файле только название): фактура — ZFW, кредит возврата — RE.
 544    internal static string? DocTypeByName(string? name)
 545    {
 9546        if (string.IsNullOrWhiteSpace(name) || name.Contains("Сторно", StringComparison.OrdinalIgnoreCase))
 1547            return null;
 10548        if (name.Contains("возврат", StringComparison.OrdinalIgnoreCase)) return "RE";
 9549        if (name.Contains("фактур", StringComparison.OrdinalIgnoreCase)) return "ZFW";
 3550        return null;
 551    }
 552
 553    /// <summary>У документа есть фактура в Armtek (getInvoice): виды из кода сайта, а без кода — по названию.</summary>
 554    internal static bool IsInvoiceDocument(string? docType, string? docTypeName) =>
 11555        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    {
 3560        var now = DateTime.UtcNow;
 3561        var parsed = rows
 6562            .Select(r => (Kunnr: ArmtekEtpParser.Str(r, "KUNNR"), Vbeln: ArmtekEtpParser.Str(r, "VBELN"), Row: r))
 6563            .Where(x => x.Kunnr != null && x.Vbeln != null)
 6564            .GroupBy(x => (x.Kunnr!, x.Vbeln!))
 6565            .Select(g => g.Last())
 3566            .ToList();
 567
 9568        var vbelns = parsed.Select(x => x.Vbeln!).Distinct().ToList();
 3569        var existing = await _context.ArmtekSettlements
 3570            .Where(x => vbelns.Contains(x.Vbeln))
 3571            .ToListAsync(ct);
 5572        var byKey = existing.ToDictionary(x => (x.Kunnr, x.Vbeln));
 573
 3574        var result = new List<ArmtekSettlement>(parsed.Count);
 3575        var created = 0;
 18576        foreach (var (kunnr, vbeln, row) in parsed)
 577        {
 6578            if (!byKey.TryGetValue((kunnr!, vbeln!), out var entity))
 579            {
 4580                entity = new ArmtekSettlement { Kunnr = kunnr!, Vbeln = vbeln!, FirstSeenAtUtc = now };
 4581                _context.ArmtekSettlements.Add(entity);
 4582                created++;
 583            }
 6584            MapSettlement(entity, row);
 6585            entity.UpdatedAtUtc = now;
 6586            result.Add(entity);
 587        }
 588
 3589        await _context.SaveChangesAsync(ct);
 3590        return (result, created);
 3591    }
 592
 593    internal static void MapSettlement(ArmtekSettlement e, JsonElement r)
 594    {
 6595        e.ClientName = Cut(ArmtekEtpParser.Str(r, "NAME"), 255);
 6596        e.DocDate = ArmtekEtpParser.SapDate(r, "DOCDATE");
 6597        e.DocType = Cut(ArmtekEtpParser.Str(r, "FKART"), 10);
 6598        e.DocTypeName = Cut(ArmtekEtpParser.Str(r, "FKART_NAME"), 255);
 6599        e.DocNumber = Cut(ArmtekEtpParser.Str(r, "DOCNUM"), 50);
 6600        e.PrintNumber = Cut(ArmtekEtpParser.Str(r, "ZZSF"), 50);
 6601        e.PaymentMethod = Cut(ArmtekEtpParser.Str(r, "ZWELS"), 10);
 6602        e.PaymentMethodName = Cut(ArmtekEtpParser.Str(r, "ZWELS_NAME"), 255);
 6603        e.Amount = ArmtekEtpParser.Dec(r, "NETWR") ?? 0;
 6604        e.ReturnAmount = ArmtekEtpParser.Dec(r, "NETWR_RE") ?? 0;
 6605        e.PaymentAmount = ArmtekEtpParser.Dec(r, "NETWR_PAY") ?? 0;
 6606        e.RemainderAmount = ArmtekEtpParser.Dec(r, "NETWR_REM") ?? 0;
 6607        e.RemainderDate = ArmtekEtpParser.SapDate(r, "REMDATE");
 6608        e.PayUntil = ArmtekEtpParser.SapDate(r, "PAYDATE");
 6609        e.Balance = ArmtekEtpParser.Dec(r, "SKFOR");
 6610        e.Currency = Cut(ArmtekEtpParser.Str(r, "WAERK"), 5);
 6611        e.Contract = Cut(ArmtekEtpParser.Str(r, "BSTKD"), 100);
 6612        e.Division = Cut(ArmtekEtpParser.Str(r, "SPART"), 10);
 6613        e.DivisionName = Cut(ArmtekEtpParser.Str(r, "SPART_NAME"), 255);
 6614        e.CompanyCode = Cut(ArmtekEtpParser.Str(r, "BUKRS"), 10);
 6615        e.CompanyName = Cut(ArmtekEtpParser.Str(r, "BUKRSN"), 255);
 6616        e.RawJson = r.GetRawText();
 6617    }
 618
 619    private static DateTime? ParseDate(string? s, string format) =>
 26620        DateTime.TryParseExact(s?.Trim(), format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : nu
 621
 532622    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

.cctor()
.ctor(OzonRepositories.Context.ApplicationDbContext,Services.ApiServices.ArmtekEtp.ArmtekEtpClient,Services.ApiServices.ArmtekApi.ArmtekByDataManager,Services.ApiServices.ArmtekEtp.ArmtekEtpConfig,Microsoft.Extensions.Logging.ILogger`1<Services.ArmtekEtpServices.ArmtekEtpSyncService>)
SyncAsync()
FinishRunAsync()
RunAsync()
LoadInvoicesFromApiAsync()
ResolvePayerAsync()
LoadPartiesAsync()
LoadInvoiceAsync()
SaveApiInvoiceAsync()
MapApiInvoice(OzonDomains.Models.ArmtekEtp.ArmtekInvoice,Services.ApiServices.ArmtekApi.Models.Response.ArmtekGetInvoiceResponse,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekOrderReportItem>,Services.ApiServices.ArmtekApi.Models.Response.ArmtekBuyerItem,System.Collections.Generic.IReadOnlyDictionary`2<System.ValueTuple`2<System.Int32,System.String>,System.String>)
BuildApiHeader(OzonDomains.Models.ArmtekEtp.ArmtekInvoice)
Add()
PartyName(System.Collections.Generic.IEnumerable`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekPartyItem>,System.String)
PartyAddress(System.Collections.Generic.IEnumerable`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekPartyItem>,System.String)
ContactName(System.Collections.Generic.IEnumerable`1<Services.ApiServices.ArmtekApi.Models.Response.ArmtekContactItem>,System.String)
SameCode(System.String,System.String)
Dec(System.String)
NullIfEmpty(System.String)
ImportSettlementsAsync()
DocTypeByName(System.String)
IsInvoiceDocument(System.String,System.String)
UpsertSettlementsAsync()
MapSettlement(OzonDomains.Models.ArmtekEtp.ArmtekSettlement,System.Text.Json.JsonElement)
ParseDate(System.String,System.String)
Cut(System.String,System.Int32)