< Summary

Information
Class: Services.ApiServices.ArmtekEtp.ArmtekEtpException
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekEtp/ArmtekEtpClient.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 326
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
.ctor(...)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;
 22    private readonly CookieContainer _cookies = new();
 23    private readonly Uri _baseUri;
 24    private bool _loggedIn;
 25
 26    public ArmtekEtpClient(IHttpClientFactory httpClientFactory, ArmtekEtpConfig config, ILogger<ArmtekEtpClient> logger
 27    {
 28        _httpClientFactory = httpClientFactory;
 29        _config = config;
 30        _logger = logger;
 31        _baseUri = new Uri(config.BaseUrl.TrimEnd('/') + "/");
 32    }
 33
 34    public async Task LoginAsync(CancellationToken ct = default)
 35    {
 36        // Как в браузере: сбытовая организация задаётся cookie VKORG ещё до входа (иначе сайт берёт её по домену).
 37        if (!string.IsNullOrWhiteSpace(_config.Vkorg))
 38            _cookies.Add(new Cookie("VKORG", _config.Vkorg.Trim(), "/", _baseUri.Host));
 39
 40        // Первый GET выдаёт сессионную cookie, без неё вход не принимается.
 41        using (await SendAsync(HttpMethod.Get, "authorization", null, ct)) { }
 42
 43        // Как браузер: KUNNR при входе пустой, клиент передаётся только при выборе из нескольких (clientChoice).
 44        var resp = await PostAuthAsync(null, ct);
 45        if (!resp.Status && resp.ClientChoice.Count > 0)
 46        {
 47            var kunnr = _config.Kunnr;
 48            if (string.IsNullOrWhiteSpace(kunnr) || !resp.ClientChoice.Contains(kunnr))
 49                kunnr = resp.ClientChoice[0];
 50            _logger.LogInformation("Armtek ETP: на логин заведено несколько клиентов ({Clients}), вход под {Kunnr}",
 51                string.Join(", ", resp.ClientChoice), kunnr);
 52            resp = await PostAuthAsync(kunnr, ct);
 53        }
 54
 55        if (resp.NeedCaptcha)
 56            throw new ArmtekEtpException(
 57                "Armtek ETP: для входа с адреса сервера сайт требует капчу (обычно после частых входов подряд). " +
 58                "Автоматически её не пройти — подождите несколько часов до следующей загрузки или войдите в кабинет вруч
 59        if (!resp.Status)
 60            throw new ArmtekEtpException("Armtek ETP: вход не выполнен. " + resp.Message);
 61        // Сайт считает вход завершённым, только когда в ответе есть redirectUrl; иначе страница перезагружается
 62        // без входа. Без этой проверки сессия оставалась анонимной, и API отвечал «Нет доступа к функционалу».
 63        if (string.IsNullOrWhiteSpace(resp.RedirectUrl))
 64            throw new ArmtekEtpException("Armtek ETP: сайт не завершил вход (нет перехода после входа). " +
 65                (string.IsNullOrWhiteSpace(resp.Message) ? "Попробуйте позже или войдите в кабинет вручную." : resp.Mess
 66
 67        // Браузер после входа переходит по redirectUrl: там сайт достраивает сессию (выбор клиента, cookie).
 68        // Без этого шага API отвечает 302 на страницу входа.
 69        var landing = await FollowAsync(resp.RedirectUrl!, ct);
 70        _logger.LogInformation("Armtek ETP: вход выполнен, переход {Redirect} → {Landing}, cookie: {Cookies}",
 71            resp.RedirectUrl ?? "/", landing.AbsolutePath, CookieNames());
 72
 73        await SelectClientAsync(ct);
 74        _loggedIn = true;
 75    }
 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    {
 84        if (!string.IsNullOrWhiteSpace(_config.Kunnr))
 85            await SetAsync("authorization/setag", _config.Kunnr.Trim(), ct);
 86        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 87            await SetAsync("authorization/setrg", _config.KunnrRg.Trim(), ct);
 88        if (!string.IsNullOrWhiteSpace(_config.Kunnr) || !string.IsNullOrWhiteSpace(_config.KunnrRg))
 89            _logger.LogInformation("Armtek ETP: выбран клиент {Kunnr}, плательщик {KunnrRg}, cookie: {Cookies}",
 90                string.IsNullOrWhiteSpace(_config.Kunnr) ? "—" : _config.Kunnr,
 91                string.IsNullOrWhiteSpace(_config.KunnrRg) ? "—" : _config.KunnrRg, CookieNames());
 92    }
 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    {
 101        var form = new Dictionary<string, string> { ["KUNNR"] = kunnr, ["FORMAT"] = "json", ["LANG"] = "ru" };
 102        var url = path + "?" + Random.Shared.NextDouble().ToString(CultureInfo.InvariantCulture);
 103        var (json, redirect) = await PostFormAsync(url, form, ct, referer: "settlements");
 104        if (json == null)
 105        {
 106            _logger.LogWarning("Armtek ETP: {Path} ({Kunnr}) ответил переходом на {Redirect}, прохожу его", path, kunnr,
 107            if (redirect != null)
 108                await FollowAsync(redirect.ToString(), ct);
 109            return;
 110        }
 111
 112        var root = ParseRoot(json);
 113        if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("status", out var status) && status.ValueKind 
 114            throw new ArmtekEtpException($"Armtek ETP: не удалось выбрать клиента {kunnr} ({path}). " + ArmtekEtpParser.
 115    }
 116
 117    /// <summary>GET с ручным проходом редиректов (до 5): cookie каждого шага сохраняются. Возвращает конечный адрес.</s
 118    private async Task<Uri> FollowAsync(string path, CancellationToken ct)
 119    {
 120        var uri = new Uri(_baseUri, path);
 121        for (var hop = 0; hop < 5; hop++)
 122        {
 123            using var response = await SendAsync(HttpMethod.Get, uri.ToString(), null, ct);
 124            if ((int)response.StatusCode is < 300 or >= 400 || response.Headers.Location == null)
 125                return uri;
 126            uri = new Uri(uri, response.Headers.Location);
 127        }
 128        return uri;
 129    }
 130
 131    private string CookieNames() =>
 132        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    {
 137        var form = new Dictionary<string, string>
 138        {
 139            ["SDATE"] = from.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 140            ["EDATE"] = to.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 141            ["FORMAT"] = "json",
 142            ["LANG"] = "ru"
 143        };
 144        if (!string.IsNullOrWhiteSpace(_config.KunnrRg))
 145            form["KUNNR_RG"] = _config.KunnrRg;
 146
 147        var json = await PostApiWithReloginAsync("settlements/settlements_search", form, ct);
 148        return ArmtekEtpParser.ParseSettlements(json);
 149    }
 150
 151    /// <summary>HTML страницы фактуры /invoice/index/{kunnr}/{vbeln}.</summary>
 152    public async Task<string> GetInvoicePageAsync(string kunnr, string vbeln, CancellationToken ct = default)
 153    {
 154        var path = $"invoice/index/{Uri.EscapeDataString(kunnr)}/{Uri.EscapeDataString(vbeln)}";
 155        var html = await GetStringAsync(path, ct);
 156        if (!ArmtekEtpParser.LooksLikeLoginPage(html))
 157            return html;
 158
 159        await LoginAsync(ct);
 160        html = await GetStringAsync(path, ct);
 161        if (ArmtekEtpParser.LooksLikeLoginPage(html))
 162            throw new ArmtekEtpException($"Armtek ETP: фактура {vbeln} недоступна — сессия не принимается");
 163        return html;
 164    }
 165
 166    private async Task<AuthResult> PostAuthAsync(string? kunnr, CancellationToken ct)
 167    {
 168        var form = new Dictionary<string, string>
 169        {
 170            ["LOGIN"] = _config.Login.TrimStart('+'),
 171            ["PASSWORD"] = _config.Password,
 172            ["CAPTCHA"] = string.Empty,
 173            ["KUNNR"] = kunnr ?? string.Empty,
 174            ["CAPTCHA_HASH"] = string.Empty
 175        };
 176        var (json, redirect) = await PostFormAsync("authorization/auth/", form, ct);
 177        if (json == null)
 178            throw new ArmtekEtpException($"Armtek ETP: вход перенаправлен на {redirect} — форма входа изменилась");
 179        return ParseAuth(json);
 180    }
 181
 182    private async Task<string> PostApiWithReloginAsync(string path, Dictionary<string, string> form, CancellationToken c
 183    {
 184        if (!_loggedIn)
 185            await LoginAsync(ct);
 186
 187        var (json, _) = await PostFormAsync(path, form, ct);
 188        if (json != null && !ArmtekEtpParser.IsSessionLost(json))
 189            return json;
 190        if (json != null)
 191            _logger.LogWarning("Armtek ETP: {Path} — сайт завершил сессию: {Messages}", path, ArmtekEtpParser.ReadMessag
 192
 193        _logger.LogWarning("Armtek ETP: {Path} — сессия не принята, повторный вход (cookie: {Cookies})", path, CookieNam
 194        await LoginAsync(ct);
 195        var (retry, redirect) = await PostFormAsync(path, form, ct);
 196        if (retry == null)
 197            throw new ArmtekEtpException($"Armtek ETP: после входа {path} перенаправляет на {redirect} — сайт не принима
 198        return retry;
 199    }
 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    {
 205        using var response = await SendAsync(HttpMethod.Post, path, form, ct, referer);
 206        if ((int)response.StatusCode is >= 300 and < 400)
 207            return (null, response.Headers.Location == null ? null : new Uri(new Uri(_baseUri, path), response.Headers.L
 208        EnsureSuccess(response);
 209        return (await response.Content.ReadAsStringAsync(ct), null);
 210    }
 211
 212    private async Task<string> GetStringAsync(string path, CancellationToken ct)
 213    {
 214        if (!_loggedIn)
 215            await LoginAsync(ct);
 216
 217        using var response = await SendAsync(HttpMethod.Get, path, null, ct);
 218        // Неавторизованный запрос редиректит на вход — отдаём тело как есть, вызывающий распознает страницу входа.
 219        if ((int)response.StatusCode is >= 300 and < 400)
 220            return string.Empty;
 221        EnsureSuccess(response);
 222        return await response.Content.ReadAsStringAsync(ct);
 223    }
 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    {
 232        var uri = new Uri(_baseUri, path);
 233        for (var attempt = 1; ; attempt++)
 234        {
 235            using var request = new HttpRequestMessage(method, uri) { Content = form == null ? null : new FormUrlEncoded
 236            request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
 237            request.Headers.TryAddWithoutValidation("Accept", form == null
 238                ? "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
 239                : "application/json, text/javascript, */*; q=0.01");
 240            request.Headers.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9");
 241            if (form != null)
 242            {
 243                request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest");
 244                request.Headers.TryAddWithoutValidation("Origin", _baseUri.GetLeftPart(UriPartial.Authority));
 245                // Referer — страница, с которой сайт сам шлёт этот AJAX: authorization/auth → /authorization, settlemen
 246                request.Headers.TryAddWithoutValidation("Referer", new Uri(_baseUri, referer ?? path.Split('/')[0]).ToSt
 247            }
 248            var cookieHeader = _cookies.GetCookieHeader(uri);
 249            if (!string.IsNullOrEmpty(cookieHeader))
 250                request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
 251
 252            HttpResponseMessage response;
 253            try
 254            {
 255                var client = _httpClientFactory.CreateClient(ArmtekEtpConfig.HttpClientName);
 256                response = await client.SendAsync(request, ct);
 257            }
 258            catch (HttpRequestException ex) when (attempt < MaxAttempts && !ct.IsCancellationRequested)
 259            {
 260                _logger.LogWarning("Armtek ETP: {Method} {Path} — сбой соединения ({Error}), попытка {Attempt}/{Max}",
 261                    method, path, ex.GetBaseException().Message, attempt, MaxAttempts);
 262                await Task.Delay(TimeSpan.FromSeconds(3 * attempt), ct);
 263                continue;
 264            }
 265
 266            if (response.Headers.TryGetValues("Set-Cookie", out var setCookies))
 267            {
 268                foreach (var setCookie in setCookies)
 269                {
 270                    try { _cookies.SetCookies(uri, setCookie); }
 271                    catch (CookieException ex) { _logger.LogDebug(ex, "Armtek ETP: cookie не разобрана"); }
 272                }
 273            }
 274            return response;
 275        }
 276    }
 277
 278    private static JsonElement ParseRoot(string json)
 279    {
 280        try { using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); }
 281        catch (JsonException) { return default; }
 282    }
 283
 284    private static void EnsureSuccess(HttpResponseMessage response)
 285    {
 286        if (response.StatusCode == HttpStatusCode.Forbidden)
 287            throw new ArmtekEtpException("Armtek ETP: доступ запрещён (403) — защита сайта не пропустила запрос сервера"
 288        response.EnsureSuccessStatusCode();
 289    }
 290
 291    internal static AuthResult ParseAuth(string json)
 292    {
 293        using var doc = JsonDocument.Parse(json);
 294        var root = doc.RootElement;
 295        var status = root.TryGetProperty("status", out var s) && s.ValueKind == JsonValueKind.True;
 296        var clients = new List<string>();
 297        var needCaptcha = false;
 298        if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
 299        {
 300            if (data.TryGetProperty("clientChoice", out var choice) && choice.ValueKind == JsonValueKind.Array)
 301            {
 302                foreach (var c in choice.EnumerateArray())
 303                {
 304                    if (c.TryGetProperty("KUNNR", out var k) && ArmtekEtpParser.AsString(k) is { Length: > 0 } kunnr)
 305                        clients.Add(kunnr);
 306                }
 307            }
 308            // Капча: явный not_valid_captcha или status=true с captchaHash (сайт показывает картинку вместо входа).
 309            needCaptcha = (data.TryGetProperty("status", out var ds) && ds.ValueKind == JsonValueKind.String
 310                    && ds.GetString() == "not_valid_captcha")
 311                || (data.TryGetProperty("captchaHash", out var ch) && ch.ValueKind == JsonValueKind.String && ch.GetStri
 312        }
 313        string? redirectUrl = null;
 314        if (root.TryGetProperty("data", out var d2) && d2.ValueKind == JsonValueKind.Object
 315            && d2.TryGetProperty("redirectUrl", out var r) && r.ValueKind == JsonValueKind.String)
 316            redirectUrl = r.GetString();
 317        return new AuthResult(status, clients, needCaptcha, ArmtekEtpParser.ReadMessages(root), redirectUrl);
 318    }
 319
 320    internal sealed record AuthResult(bool Status, List<string> ClientChoice, bool NeedCaptcha, string Message, string? 
 321}
 322
 323public sealed class ArmtekEtpException : Exception
 324{
 16325    public ArmtekEtpException(string message) : base(message) { }
 326}

Methods/Properties

.ctor(System.String)