< Summary

Information
Class: Services.ApiServices.ArmtekEtp.ArmtekEtpInvoicePage
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekEtp/ArmtekEtpParser.cs
Line coverage
100%
Covered lines: 4
Uncovered lines: 0
Coverable lines: 4
Total lines: 219
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%
get_Header()100%11100%
get_Items()100%11100%
get_Columns()100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text;
 3using System.Text.Json;
 4using System.Text.RegularExpressions;
 5using HtmlAgilityPack;
 6
 7namespace Services.ApiServices.ArmtekEtp;
 8
 9/// <summary>Строка шапки фактуры: «название: [код] значение».</summary>
 10public sealed record ArmtekEtpHeaderField(string Label, string? Code, string Text, string? Hidden);
 11
 12/// <summary>Колонка таблицы позиций фактуры: заголовок и поле report_info, из которого она строится.</summary>
 13public sealed record ArmtekEtpColumn(string Title, string? Field, bool Visible);
 14
 115public sealed record ArmtekEtpInvoicePage(
 216    IReadOnlyList<ArmtekEtpHeaderField> Header,
 317    IReadOnlyList<JsonElement> Items,
 318    IReadOnlyList<ArmtekEtpColumn> Columns);
 19
 20/// <summary>Разбор ответов etp.armtek.ru. Без сети — чтобы проверяться тестами на сохранённых ответах.</summary>
 21public static class ArmtekEtpParser
 22{
 23    private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
 24    private static readonly Regex SpacesRegex = new(@"\s+", RegexOptions.Compiled);
 25
 26    public static IReadOnlyList<JsonElement> ParseSettlements(string json)
 27    {
 28        using var doc = JsonDocument.Parse(json);
 29        var root = doc.RootElement;
 30        if (!(root.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.True))
 31            throw new ArmtekEtpException("Armtek ETP: взаиморасчёты не получены. " + ReadMessages(root));
 32
 33        if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
 34            return Array.Empty<JsonElement>();
 35
 36        return data.EnumerateArray().Select(x => x.Clone()).ToList();
 37    }
 38
 39    /// <summary>Ответ AJAX вместо JSON (страница входа) или сообщение типа A («сессия завершена»).</summary>
 40    public static bool IsSessionLost(string json)
 41    {
 42        if (string.IsNullOrWhiteSpace(json) || json.TrimStart()[0] != '{')
 43            return true;
 44        try
 45        {
 46            using var doc = JsonDocument.Parse(json);
 47            if (!doc.RootElement.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind
 48                return false;
 49            // Сообщение типа A сайт сам обрабатывает выходом из кабинета (informModal → /authorization/logout).
 50            return messages.EnumerateArray().Any(m => MessageType(m) == "A");
 51        }
 52        catch (JsonException)
 53        {
 54            return true;
 55        }
 56    }
 57
 58    public static bool LooksLikeLoginPage(string html) =>
 59        string.IsNullOrWhiteSpace(html)
 60        || (html.Contains("sign-in-form", StringComparison.Ordinal) && !html.Contains("report_info", StringComparison.Or
 61
 62    public static ArmtekEtpInvoicePage ParseInvoicePage(string html)
 63    {
 64        var itemsJson = ExtractJsValue(html, "report_info")
 65            ?? throw new ArmtekEtpException("Armtek ETP: на странице фактуры нет report_info — изменилась вёрстка");
 66        using var items = JsonDocument.Parse(itemsJson);
 67        var rows = items.RootElement.ValueKind == JsonValueKind.Array
 68            ? items.RootElement.EnumerateArray().Select(x => x.Clone()).ToList()
 69            : new List<JsonElement>();
 70
 71        var columns = new List<ArmtekEtpColumn>();
 72        if (ExtractJsValue(html, "report_settings") is { } settingsJson)
 73        {
 74            using var settings = JsonDocument.Parse(settingsJson);
 75            foreach (var c in settings.RootElement.EnumerateArray())
 76            {
 77                var title = c.TryGetProperty("title", out var t) ? StripHtml(AsString(t) ?? string.Empty) : string.Empty
 78                var field = c.TryGetProperty("data", out var d) ? AsString(d) : null;
 79                var visible = !c.TryGetProperty("visible", out var v) || v.ValueKind != JsonValueKind.False;
 80                columns.Add(new ArmtekEtpColumn(title, field, visible));
 81            }
 82        }
 83
 84        return new ArmtekEtpInvoicePage(ParseHeader(html), rows, columns);
 85    }
 86
 87    private static List<ArmtekEtpHeaderField> ParseHeader(string html)
 88    {
 89        var doc = new HtmlDocument();
 90        doc.LoadHtml(html);
 91        var result = new List<ArmtekEtpHeaderField>();
 92        var rows = doc.DocumentNode.SelectNodes(
 93            "//div[contains(@class,'invoice-header-container')]//div[contains(concat(' ',normalize-space(@class),' '),' 
 94        if (rows == null)
 95            return result;
 96
 97        foreach (var row in rows)
 98        {
 99            var label = row.SelectSingleNode(".//*[contains(@class,'row-title')]");
 100            var content = row.SelectSingleNode(".//div[contains(@class,'row-content')]");
 101            if (label == null || content == null)
 102                continue;
 103
 104            var code = content.SelectSingleNode(".//*[contains(@class,'partner_nu')]");
 105            var hidden = content.SelectSingleNode(".//input[@type='hidden']");
 106            result.Add(new ArmtekEtpHeaderField(
 107                Clean(label.InnerText).TrimEnd(':'),
 108                code == null ? null : Clean(code.InnerText),
 109                Clean(content.InnerText),
 110                hidden?.GetAttributeValue("value", null)));
 111        }
 112        return result;
 113    }
 114
 115    /// <summary>
 116    /// Значение JS-переменной вида <c>var name = [...];</c> / <c>{...}</c>: литерал вырезается по балансу скобок
 117    /// с учётом строк, потому что внутри наименований встречаются и скобки, и экранированные кавычки.
 118    /// </summary>
 119    public static string? ExtractJsValue(string html, string name)
 120    {
 121        var match = Regex.Match(html, @"var\s+" + Regex.Escape(name) + @"\s*=\s*(?=[\[{])");
 122        if (!match.Success)
 123            return null;
 124
 125        var start = match.Index + match.Length;
 126        var depth = 0;
 127        var inString = false;
 128        for (var i = start; i < html.Length; i++)
 129        {
 130            var ch = html[i];
 131            if (inString)
 132            {
 133                if (ch == '\\') i++;
 134                else if (ch == '"') inString = false;
 135                continue;
 136            }
 137            switch (ch)
 138            {
 139                case '"': inString = true; break;
 140                case '[': case '{': depth++; break;
 141                case ']': case '}':
 142                    if (--depth == 0)
 143                        return html.Substring(start, i - start + 1);
 144                    break;
 145            }
 146        }
 147        return null;
 148    }
 149
 150    /// <summary>
 151    /// Тексты arr_messages. На сайте элемент — объект {type, text, …} (showMessage берёт msg.text); регистр полей
 152    /// не фиксируем. Если text нет — все строковые поля, кроме type, чтобы в ошибке не терялся смысл.
 153    /// </summary>
 154    public static string ReadMessages(JsonElement root)
 155    {
 156        if (root.ValueKind != JsonValueKind.Object
 157            || !root.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
 158            return string.Empty;
 159        var parts = new List<string>();
 160        foreach (var m in messages.EnumerateArray())
 161        {
 162            string? text = null;
 163            if (m.ValueKind == JsonValueKind.String)
 164                text = m.GetString();
 165            else if (m.ValueKind == JsonValueKind.Object)
 166            {
 167                var props = m.EnumerateObject().Where(p => p.Value.ValueKind is JsonValueKind.String or JsonValueKind.Nu
 168                text = props.FirstOrDefault(p => p.Name.Equals("text", StringComparison.OrdinalIgnoreCase)
 169                        || p.Name.Equals("message", StringComparison.OrdinalIgnoreCase)).Value is { ValueKind: not JsonV
 170                    ? AsString(t)
 171                    : string.Join(", ", props.Where(p => !p.Name.Equals("type", StringComparison.OrdinalIgnoreCase))
 172                        .Select(p => p.Name + ": " + AsString(p.Value)));
 173                var type = MessageType(m);
 174                if (!string.IsNullOrWhiteSpace(text) && type != null)
 175                    text = $"[{type}] {text}";
 176            }
 177            if (!string.IsNullOrWhiteSpace(text))
 178                parts.Add(StripHtml(text));
 179        }
 180        return string.Join("; ", parts);
 181    }
 182
 183    private static string? MessageType(JsonElement m) =>
 184        m.ValueKind == JsonValueKind.Object
 185            ? m.EnumerateObject().FirstOrDefault(p => p.Name.Equals("type", StringComparison.OrdinalIgnoreCase)).Value i
 186                ? t.GetString()
 187                : null
 188            : null;
 189
 190    public static string? AsString(JsonElement e) => e.ValueKind switch
 191    {
 192        JsonValueKind.String => e.GetString(),
 193        JsonValueKind.Number => e.GetRawText(),
 194        JsonValueKind.True => "true",
 195        JsonValueKind.False => "false",
 196        _ => null
 197    };
 198
 199    public static string? Str(JsonElement row, string name)
 200    {
 201        if (!row.TryGetProperty(name, out var v))
 202            return null;
 203        var s = AsString(v)?.Trim();
 204        return string.IsNullOrEmpty(s) ? null : s;
 205    }
 206
 207    public static decimal? Dec(JsonElement row, string name) =>
 208        decimal.TryParse(Str(row, name), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null;
 209
 210    /// <summary>Дата SAP yyyyMMdd; «00000000» и пусто — null.</summary>
 211    public static DateTime? SapDate(JsonElement row, string name) => ParseSapDate(Str(row, name));
 212
 213    public static DateTime? ParseSapDate(string? s) =>
 214        DateTime.TryParseExact(s, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : null;
 215
 216    private static string StripHtml(string s) => Clean(TagRegex.Replace(s, string.Empty));
 217
 218    private static string Clean(string s) => SpacesRegex.Replace(HtmlEntity.DeEntitize(s), " ").Trim();
 219}