< Summary

Information
Class: Services.ApiServices.ArmtekEtp.ArmtekEtpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekEtp/ArmtekEtpClient.cs
Line coverage
84%
Covered lines: 154
Uncovered lines: 28
Coverable lines: 182
Total lines: 326
Line coverage: 84.6%
Branch coverage
82%
Covered branches: 114
Total branches: 138
Branch coverage: 82.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
LoginAsync()60%282073.07%
SelectClientAsync()91.66%1212100%
SetAsync()100%1010100%
FollowAsync()90%101087.5%
CookieNames()100%11100%
GetSettlementsAsync()100%22100%
GetInvoicePageAsync()0%2040%
PostAuthAsync()75%4492.3%
PostApiWithReloginAsync()100%1010100%
PostFormAsync()83.33%66100%
GetStringAsync()0%4260%
SendAsync()100%141496.55%
ParseRoot(...)100%11100%
EnsureSuccess(...)100%22100%
ParseAuth(...)94.73%3838100%
get_Status()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekEtp/ArmtekEtpClient.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net;
 3using System.Text.Json;
 4using Microsoft.Extensions.Logging;
 5
 6namespace Services.ApiServices.ArmtekEtp;
 7
 8/// <summary>
 9/// HTTP-клиент личного кабинета Armtek (etp.armtek.ru) без браузера: вход формой, затем те же AJAX-запросы,
 10/// что делает сайт. Сессия (cookie) живёт в экземпляре, поэтому регистрируется как transient — один на запуск синка.
 11/// Cookie ведутся вручную: у именованного клиента UseCookies=false, обработчик фабрики общий для всех.
 12/// </summary>
 13public class ArmtekEtpClient
 14{
 15    private const int MaxAttempts = 3;
 16    private const string BrowserUserAgent =
 17        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
 18
 19    private readonly IHttpClientFactory _httpClientFactory;
 20    private readonly ArmtekEtpConfig _config;
 21    private readonly ILogger<ArmtekEtpClient> _logger;
 2022    private readonly CookieContainer _cookies = new();
 23    private readonly Uri _baseUri;
 24    private bool _loggedIn;
 25
 2026    public ArmtekEtpClient(IHttpClientFactory httpClientFactory, ArmtekEtpConfig config, ILogger<ArmtekEtpClient> logger
 27    {
 2028        _httpClientFactory = httpClientFactory;
 2029        _config = config;
 2030        _logger = logger;
 2031        _baseUri = new Uri(config.BaseUrl.TrimEnd('/') + "/");
 2032    }
 33
 34    public async Task LoginAsync(CancellationToken ct = default)
 35    {
 36        // Как в браузере: сбытовая организация задаётся cookie VKORG ещё до входа (иначе сайт берёт её по домену).
 1837        if (!string.IsNullOrWhiteSpace(_config.Vkorg))
 1838            _cookies.Add(new Cookie("VKORG", _config.Vkorg.Trim(), "/", _baseUri.Host));
 39
 40        // Первый GET выдаёт сессионную cookie, без неё вход не принимается.
 3641        using (await SendAsync(HttpMethod.Get, "authorization", null, ct)) { }
 42
 43        // Как браузер: KUNNR при входе пустой, клиент передаётся только при выборе из нескольких (clientChoice).
 1844        var resp = await PostAuthAsync(null, ct);
 1745        if (!resp.Status && resp.ClientChoice.Count > 0)
 46        {
 047            var kunnr = _config.Kunnr;
 048            if (string.IsNullOrWhiteSpace(kunnr) || !resp.ClientChoice.Contains(kunnr))
 049                kunnr = resp.ClientChoice[0];
 050            _logger.LogInformation("Armtek ETP: на логин заведено несколько клиентов ({Clients}), вход под {Kunnr}",
 051                string.Join(", ", resp.ClientChoice), kunnr);
 052            resp = await PostAuthAsync(kunnr, ct);
 53        }
 54
 1755        if (resp.NeedCaptcha)
 356            throw new ArmtekEtpException(
 357                "Armtek ETP: для входа с адреса сервера сайт требует капчу (обычно после частых входов подряд). " +
 358                "Автоматически её не пройти — подождите несколько часов до следующей загрузки или войдите в кабинет вруч
 1459        if (!resp.Status)
 060            throw new ArmtekEtpException("Armtek ETP: вход не выполнен. " + resp.Message);
 61        // Сайт считает вход завершённым, только когда в ответе есть redirectUrl; иначе страница перезагружается
 62        // без входа. Без этой проверки сессия оставалась анонимной, и API отвечал «Нет доступа к функционалу».
 1463        if (string.IsNullOrWhiteSpace(resp.RedirectUrl))
 164            throw new ArmtekEtpException("Armtek ETP: сайт не завершил вход (нет перехода после входа). " +
 165                (string.IsNullOrWhiteSpace(resp.Message) ? "Попробуйте позже или войдите в кабинет вручную." : resp.Mess
 66
 67        // Браузер после входа переходит по redirectUrl: там сайт достраивает сессию (выбор клиента, cookie).
 68        // Без этого шага API отвечает 302 на страницу входа.
 1369        var landing = await FollowAsync(resp.RedirectUrl!, ct);
 1370        _logger.LogInformation("Armtek ETP: вход выполнен, переход {Redirect} → {Landing}, cookie: {Cookies}",
 1371            resp.RedirectUrl ?? "/", landing.AbsolutePath, CookieNames());
 72
 1373        await SelectClientAsync(ct);
 1274        _loggedIn = true;
 1275    }
 76
 77    /// <summary>
 78    /// Выбор клиента (KUNNR_AG, только если на логине их несколько — /authorization/setag) и плательщика
 79    /// (KUNNR_RG — /authorization/setrg, как клик в списке плательщиков в шапке кабинета). После входа сайт берёт
 80    /// плательщика по умолчанию; если это, например, «Розница», API взаиморасчётов отвечает «Нет доступа к функционалу»
 81    /// </summary>
 82    private async Task SelectClientAsync(CancellationToken ct)
 83    {
 1384        if (!string.IsNullOrWhiteSpace(_config.Kunnr))
 285            await SetAsync("authorization/setag", _config.Kunnr.Trim(), ct);
 1286        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 687            await SetAsync("authorization/setrg", _config.KunnrRg.Trim(), ct);
 1288        if (!string.IsNullOrWhiteSpace(_config.Kunnr) || !string.IsNullOrWhiteSpace(_config.KunnrRg))
 689            _logger.LogInformation("Armtek ETP: выбран клиент {Kunnr}, плательщик {KunnrRg}, cookie: {Cookies}",
 690                string.IsNullOrWhiteSpace(_config.Kunnr) ? "—" : _config.Kunnr,
 691                string.IsNullOrWhiteSpace(_config.KunnrRg) ? "—" : _config.KunnrRg, CookieNames());
 1292    }
 93
 94    /// <summary>
 95    /// setag/setrg так, как их шлёт сайт: AJAX со страницы кабинета (Referer — взаиморасчёты) и со случайным
 96    /// параметром в адресе (getApiAjax добавляет «?Math.random()»). Редирект — не ошибка: сайт после выбора
 97    /// уводит на главную; его проходим, а принятие выбора проверит следующий запрос API.
 98    /// </summary>
 99    private async Task SetAsync(string path, string kunnr, CancellationToken ct)
 100    {
 8101        var form = new Dictionary<string, string> { ["KUNNR"] = kunnr, ["FORMAT"] = "json", ["LANG"] = "ru" };
 8102        var url = path + "?" + Random.Shared.NextDouble().ToString(CultureInfo.InvariantCulture);
 8103        var (json, redirect) = await PostFormAsync(url, form, ct, referer: "settlements");
 8104        if (json == null)
 105        {
 1106            _logger.LogWarning("Armtek ETP: {Path} ({Kunnr}) ответил переходом на {Redirect}, прохожу его", path, kunnr,
 1107            if (redirect != null)
 1108                await FollowAsync(redirect.ToString(), ct);
 1109            return;
 110        }
 111
 7112        var root = ParseRoot(json);
 7113        if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("status", out var status) && status.ValueKind 
 1114            throw new ArmtekEtpException($"Armtek ETP: не удалось выбрать клиента {kunnr} ({path}). " + ArmtekEtpParser.
 7115    }
 116
 117    /// <summary>GET с ручным проходом редиректов (до 5): cookie каждого шага сохраняются. Возвращает конечный адрес.</s
 118    private async Task<Uri> FollowAsync(string path, CancellationToken ct)
 119    {
 14120        var uri = new Uri(_baseUri, path);
 30121        for (var hop = 0; hop < 5; hop++)
 122        {
 15123            using var response = await SendAsync(HttpMethod.Get, uri.ToString(), null, ct);
 15124            if ((int)response.StatusCode is < 300 or >= 400 || response.Headers.Location == null)
 14125                return uri;
 1126            uri = new Uri(uri, response.Headers.Location);
 127        }
 0128        return uri;
 14129    }
 130
 131    private string CookieNames() =>
 51132        string.Join(", ", _cookies.GetAllCookies().Select(c => c.Name + "@" + c.Domain).Distinct());
 133
 134    /// <summary>Строки «Взаиморасчётов» за период как есть (элементы массива data).</summary>
 135    public async Task<IReadOnlyList<JsonElement>> GetSettlementsAsync(DateOnly from, DateOnly to, CancellationToken ct =
 136    {
 6137        var form = new Dictionary<string, string>
 6138        {
 6139            ["SDATE"] = from.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 6140            ["EDATE"] = to.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 6141            ["FORMAT"] = "json",
 6142            ["LANG"] = "ru"
 6143        };
 6144        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 3145            form["KUNNR_RG"] = _config.KunnrRg;
 146
 6147        var json = await PostApiWithReloginAsync("settlements/settlements_search", form, ct);
 5148        return ArmtekEtpParser.ParseSettlements(json);
 5149    }
 150
 151    /// <summary>HTML страницы фактуры /invoice/index/{kunnr}/{vbeln}.</summary>
 152    public async Task<string> GetInvoicePageAsync(string kunnr, string vbeln, CancellationToken ct = default)
 153    {
 0154        var path = $"invoice/index/{Uri.EscapeDataString(kunnr)}/{Uri.EscapeDataString(vbeln)}";
 0155        var html = await GetStringAsync(path, ct);
 0156        if (!ArmtekEtpParser.LooksLikeLoginPage(html))
 0157            return html;
 158
 0159        await LoginAsync(ct);
 0160        html = await GetStringAsync(path, ct);
 0161        if (ArmtekEtpParser.LooksLikeLoginPage(html))
 0162            throw new ArmtekEtpException($"Armtek ETP: фактура {vbeln} недоступна — сессия не принимается");
 0163        return html;
 0164    }
 165
 166    private async Task<AuthResult> PostAuthAsync(string? kunnr, CancellationToken ct)
 167    {
 18168        var form = new Dictionary<string, string>
 18169        {
 18170            ["LOGIN"] = _config.Login.TrimStart('+'),
 18171            ["PASSWORD"] = _config.Password,
 18172            ["CAPTCHA"] = string.Empty,
 18173            ["KUNNR"] = kunnr ?? string.Empty,
 18174            ["CAPTCHA_HASH"] = string.Empty
 18175        };
 18176        var (json, redirect) = await PostFormAsync("authorization/auth/", form, ct);
 17177        if (json == null)
 0178            throw new ArmtekEtpException($"Armtek ETP: вход перенаправлен на {redirect} — форма входа изменилась");
 17179        return ParseAuth(json);
 17180    }
 181
 182    private async Task<string> PostApiWithReloginAsync(string path, Dictionary<string, string> form, CancellationToken c
 183    {
 6184        if (!_loggedIn)
 3185            await LoginAsync(ct);
 186
 6187        var (json, _) = await PostFormAsync(path, form, ct);
 6188        if (json != null && !ArmtekEtpParser.IsSessionLost(json))
 4189            return json;
 2190        if (json != null)
 1191            _logger.LogWarning("Armtek ETP: {Path} — сайт завершил сессию: {Messages}", path, ArmtekEtpParser.ReadMessag
 192
 2193        _logger.LogWarning("Armtek ETP: {Path} — сессия не принята, повторный вход (cookie: {Cookies})", path, CookieNam
 2194        await LoginAsync(ct);
 2195        var (retry, redirect) = await PostFormAsync(path, form, ct);
 2196        if (retry == null)
 1197            throw new ArmtekEtpException($"Armtek ETP: после входа {path} перенаправляет на {redirect} — сайт не принима
 1198        return retry;
 5199    }
 200
 201    /// <summary>POST формы. Редирект (сессия не принята — сайт отправляет на вход) возвращается как (null, адрес).</sum
 202    private async Task<(string? Body, Uri? Redirect)> PostFormAsync(string path, Dictionary<string, string> form, Cancel
 203        string? referer = null)
 204    {
 34205        using var response = await SendAsync(HttpMethod.Post, path, form, ct, referer);
 34206        if ((int)response.StatusCode is >= 300 and < 400)
 3207            return (null, response.Headers.Location == null ? null : new Uri(new Uri(_baseUri, path), response.Headers.L
 31208        EnsureSuccess(response);
 30209        return (await response.Content.ReadAsStringAsync(ct), null);
 33210    }
 211
 212    private async Task<string> GetStringAsync(string path, CancellationToken ct)
 213    {
 0214        if (!_loggedIn)
 0215            await LoginAsync(ct);
 216
 0217        using var response = await SendAsync(HttpMethod.Get, path, null, ct);
 218        // Неавторизованный запрос редиректит на вход — отдаём тело как есть, вызывающий распознает страницу входа.
 0219        if ((int)response.StatusCode is >= 300 and < 400)
 0220            return string.Empty;
 0221        EnsureSuccess(response);
 0222        return await response.Content.ReadAsStringAsync(ct);
 0223    }
 224
 225    /// <summary>
 226    /// Запрос «как браузер»: без User-Agent браузера защита сайта отвечает 403. Обрыв соединения или TLS
 227    /// (защита периодически сбрасывает рукопожатие) повторяется до <see cref="MaxAttempts"/> раз с паузой.
 228    /// </summary>
 229    private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, Dictionary<string, string>? form, 
 230        string? referer = null)
 231    {
 67232        var uri = new Uri(_baseUri, path);
 68233        for (var attempt = 1; ; attempt++)
 234        {
 68235            using var request = new HttpRequestMessage(method, uri) { Content = form == null ? null : new FormUrlEncoded
 68236            request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
 68237            request.Headers.TryAddWithoutValidation("Accept", form == null
 68238                ? "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
 68239                : "application/json, text/javascript, */*; q=0.01");
 68240            request.Headers.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9");
 68241            if (form != null)
 242            {
 34243                request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
 34244                request.Headers.TryAddWithoutValidation("Origin", _baseUri.GetLeftPart(UriPartial.Authority));
 245                // Referer — страница, с которой сайт сам шлёт этот AJAX: authorization/auth → /authorization, settlemen
 34246                request.Headers.TryAddWithoutValidation("Referer", new Uri(_baseUri, referer ?? path.Split('/')[0]).ToSt
 247            }
 68248            var cookieHeader = _cookies.GetCookieHeader(uri);
 68249            if (!string.IsNullOrEmpty(cookieHeader))
 68250                request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
 251
 252            HttpResponseMessage response;
 253            try
 254            {
 68255                var client = _httpClientFactory.CreateClient(ArmtekEtpConfig.HttpClientName);
 68256                response = await client.SendAsync(request, ct);
 67257            }
 1258            catch (HttpRequestException ex) when (attempt < MaxAttempts && !ct.IsCancellationRequested)
 259            {
 1260                _logger.LogWarning("Armtek ETP: {Method} {Path} — сбой соединения ({Error}), попытка {Attempt}/{Max}",
 1261                    method, path, ex.GetBaseException().Message, attempt, MaxAttempts);
 1262                await Task.Delay(TimeSpan.FromSeconds(3 * attempt), ct);
 1263                continue;
 264            }
 265
 67266            if (response.Headers.TryGetValues("Set-Cookie", out var setCookies))
 267            {
 28268                foreach (var setCookie in setCookies)
 269                {
 14270                    try { _cookies.SetCookies(uri, setCookie); }
 0271                    catch (CookieException ex) { _logger.LogDebug(ex, "Armtek ETP: cookie не разобрана"); }
 272                }
 273            }
 67274            return response;
 275        }
 67276    }
 277
 278    private static JsonElement ParseRoot(string json)
 279    {
 13280        try { using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); }
 6281        catch (JsonException) { return default; }
 8282    }
 283
 284    private static void EnsureSuccess(HttpResponseMessage response)
 285    {
 31286        if (response.StatusCode == HttpStatusCode.Forbidden)
 1287            throw new ArmtekEtpException("Armtek ETP: доступ запрещён (403) — защита сайта не пропустила запрос сервера"
 30288        response.EnsureSuccessStatusCode();
 30289    }
 290
 291    internal static AuthResult ParseAuth(string json)
 292    {
 19293        using var doc = JsonDocument.Parse(json);
 19294        var root = doc.RootElement;
 19295        var status = root.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.True;
 19296        var clients = new List<string>();
 19297        var needCaptcha = false;
 19298        if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
 299        {
 19300            if (data.TryGetProperty("clientChoice", out var choice) && choice.ValueKind == JsonValueKind.Array)
 301            {
 6302                foreach (var c in choice.EnumerateArray())
 303                {
 2304                    if (c.TryGetProperty("KUNNR", out var k) && ArmtekEtpParser.AsString(k) is { Length: > 0 } kunnr)
 2305                        clients.Add(kunnr);
 306                }
 307            }
 308            // Капча: явный not_valid_captcha или status=true с captchaHash (сайт показывает картинку вместо входа).
 19309            needCaptcha = (data.TryGetProperty("status", out var ds) && ds.ValueKind == JsonValueKind.String
 19310                    && ds.GetString() == "not_valid_captcha")
 19311                || (data.TryGetProperty("captchaHash", out var ch) && ch.ValueKind == JsonValueKind.String && ch.GetStri
 312        }
 19313        string? redirectUrl = null;
 19314        if (root.TryGetProperty("data", out var d2) && d2.ValueKind == JsonValueKind.Object
 19315            && d2.TryGetProperty("redirectUrl", out var r) && r.ValueKind == JsonValueKind.String)
 13316            redirectUrl = r.GetString();
 19317        return new AuthResult(status, clients, needCaptcha, ArmtekEtpParser.ReadMessages(root), redirectUrl);
 19318    }
 319
 111320    internal sealed record AuthResult(bool Status, List<string> ClientChoice, bool NeedCaptcha, string Message, string? 
 321}
 322
 323public sealed class ArmtekEtpException : Exception
 324{
 325    public ArmtekEtpException(string message) : base(message) { }
 326}