< Summary

Information
Class: Services.ApiServices.ArmtekEtp.ArmtekEtpParser
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekEtp/ArmtekEtpParser.cs
Line coverage
88%
Covered lines: 103
Uncovered lines: 13
Coverable lines: 116
Total lines: 219
Line coverage: 88.7%
Branch coverage
78%
Covered branches: 90
Total branches: 115
Branch coverage: 78.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
ParseSettlements(...)75%8887.5%
IsSessionLost(...)75%10866.66%
LooksLikeLoginPage(...)0%2040%
ParseInvoicePage(...)62.5%1616100%
ParseHeader(...)91.66%121294.73%
ExtractJsValue(...)95.83%242494.11%
ReadMessages(...)87.5%242490.47%
MessageType(...)50%44100%
AsString(...)40%6562.5%
Str(...)83.33%66100%
Dec(...)100%22100%
SapDate(...)100%11100%
ParseSapDate(...)100%22100%
StripHtml(...)100%11100%
Clean(...)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
 15public sealed record ArmtekEtpInvoicePage(
 16    IReadOnlyList<ArmtekEtpHeaderField> Header,
 17    IReadOnlyList<JsonElement> Items,
 18    IReadOnlyList<ArmtekEtpColumn> Columns);
 19
 20/// <summary>Разбор ответов etp.armtek.ru. Без сети — чтобы проверяться тестами на сохранённых ответах.</summary>
 21public static class ArmtekEtpParser
 22{
 123    private static readonly Regex TagRegex = new("<[^>]+>", RegexOptions.Compiled);
 124    private static readonly Regex SpacesRegex = new(@"\s+", RegexOptions.Compiled);
 25
 26    public static IReadOnlyList<JsonElement> ParseSettlements(string json)
 27    {
 628        using var doc = JsonDocument.Parse(json);
 629        var root = doc.RootElement;
 630        if (!(root.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.True))
 131            throw new ArmtekEtpException("Armtek ETP: взаиморасчёты не получены. " + ReadMessages(root));
 32
 533        if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
 034            return Array.Empty<JsonElement>();
 35
 1536        return data.EnumerateArray().Select(x => x.Clone()).ToList();
 537    }
 38
 39    /// <summary>Ответ AJAX вместо JSON (страница входа) или сообщение типа A («сессия завершена»).</summary>
 40    public static bool IsSessionLost(string json)
 41    {
 942        if (string.IsNullOrWhiteSpace(json) || json.TrimStart()[0] != '{')
 143            return true;
 44        try
 45        {
 846            using var doc = JsonDocument.Parse(json);
 847            if (!doc.RootElement.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind
 048                return false;
 49            // Сообщение типа A сайт сам обрабатывает выходом из кабинета (informModal → /authorization/logout).
 1150            return messages.EnumerateArray().Any(m => MessageType(m) == "A");
 51        }
 052        catch (JsonException)
 53        {
 054            return true;
 55        }
 856    }
 57
 58    public static bool LooksLikeLoginPage(string html) =>
 059        string.IsNullOrWhiteSpace(html)
 060        || (html.Contains("sign-in-form", StringComparison.Ordinal) && !html.Contains("report_info", StringComparison.Or
 61
 62    public static ArmtekEtpInvoicePage ParseInvoicePage(string html)
 63    {
 164        var itemsJson = ExtractJsValue(html, "report_info")
 165            ?? throw new ArmtekEtpException("Armtek ETP: на странице фактуры нет report_info — изменилась вёрстка");
 166        using var items = JsonDocument.Parse(itemsJson);
 167        var rows = items.RootElement.ValueKind == JsonValueKind.Array
 268            ? items.RootElement.EnumerateArray().Select(x => x.Clone()).ToList()
 169            : new List<JsonElement>();
 70
 171        var columns = new List<ArmtekEtpColumn>();
 172        if (ExtractJsValue(html, "report_settings") is { } settingsJson)
 73        {
 174            using var settings = JsonDocument.Parse(settingsJson);
 675            foreach (var c in settings.RootElement.EnumerateArray())
 76            {
 277                var title = c.TryGetProperty("title", out var t) ? StripHtml(AsString(t) ?? string.Empty) : string.Empty
 278                var field = c.TryGetProperty("data", out var d) ? AsString(d) : null;
 279                var visible = !c.TryGetProperty("visible", out var v) || v.ValueKind != JsonValueKind.False;
 280                columns.Add(new ArmtekEtpColumn(title, field, visible));
 81            }
 82        }
 83
 184        return new ArmtekEtpInvoicePage(ParseHeader(html), rows, columns);
 185    }
 86
 87    private static List<ArmtekEtpHeaderField> ParseHeader(string html)
 88    {
 189        var doc = new HtmlDocument();
 190        doc.LoadHtml(html);
 191        var result = new List<ArmtekEtpHeaderField>();
 192        var rows = doc.DocumentNode.SelectNodes(
 193            "//div[contains(@class,'invoice-header-container')]//div[contains(concat(' ',normalize-space(@class),' '),' 
 194        if (rows == null)
 095            return result;
 96
 1897        foreach (var row in rows)
 98        {
 899            var label = row.SelectSingleNode(".//*[contains(@class,'row-title')]");
 8100            var content = row.SelectSingleNode(".//div[contains(@class,'row-content')]");
 8101            if (label == null || content == null)
 102                continue;
 103
 8104            var code = content.SelectSingleNode(".//*[contains(@class,'partner_nu')]");
 8105            var hidden = content.SelectSingleNode(".//input[@type='hidden']");
 8106            result.Add(new ArmtekEtpHeaderField(
 8107                Clean(label.InnerText).TrimEnd(':'),
 8108                code == null ? null : Clean(code.InnerText),
 8109                Clean(content.InnerText),
 8110                hidden?.GetAttributeValue("value", null)));
 111        }
 1112        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    {
 4121        var match = Regex.Match(html, @"var\s+" + Regex.Escape(name) + @"\s*=\s*(?=[\[{])");
 4122        if (!match.Success)
 1123            return null;
 124
 3125        var start = match.Index + match.Length;
 3126        var depth = 0;
 3127        var inString = false;
 1568128        for (var i = start; i < html.Length; i++)
 129        {
 784130            var ch = html[i];
 784131            if (inString)
 132            {
 597133                if (ch == '\\') i++;
 642134                else if (ch == '"') inString = false;
 71135                continue;
 136            }
 137            switch (ch)
 138            {
 142139                case '"': inString = true; break;
 20140                case '[': case '{': depth++; break;
 141                case ']': case '}':
 10142                    if (--depth == 0)
 3143                        return html.Substring(start, i - start + 1);
 144                    break;
 145            }
 146        }
 0147        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    {
 23156        if (root.ValueKind != JsonValueKind.Object
 23157            || !root.TryGetProperty("arr_messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
 19158            return string.Empty;
 4159        var parts = new List<string>();
 16160        foreach (var m in messages.EnumerateArray())
 161        {
 4162            string? text = null;
 4163            if (m.ValueKind == JsonValueKind.String)
 0164                text = m.GetString();
 4165            else if (m.ValueKind == JsonValueKind.Object)
 166            {
 15167                var props = m.EnumerateObject().Where(p => p.Value.ValueKind is JsonValueKind.String or JsonValueKind.Nu
 15168                text = props.FirstOrDefault(p => p.Name.Equals("text", StringComparison.OrdinalIgnoreCase)
 15169                        || p.Name.Equals("message", StringComparison.OrdinalIgnoreCase)).Value is { ValueKind: not JsonV
 4170                    ? AsString(t)
 0171                    : string.Join(", ", props.Where(p => !p.Name.Equals("type", StringComparison.OrdinalIgnoreCase))
 4172                        .Select(p => p.Name + ": " + AsString(p.Value)));
 4173                var type = MessageType(m);
 4174                if (!string.IsNullOrWhiteSpace(text) && type != null)
 4175                    text = $"[{type}] {text}";
 176            }
 4177            if (!string.IsNullOrWhiteSpace(text))
 4178                parts.Add(StripHtml(text));
 179        }
 4180        return string.Join("; ", parts);
 181    }
 182
 183    private static string? MessageType(JsonElement m) =>
 7184        m.ValueKind == JsonValueKind.Object
 7185            ? m.EnumerateObject().FirstOrDefault(p => p.Name.Equals("type", StringComparison.OrdinalIgnoreCase)).Value i
 7186                ? t.GetString()
 7187                : null
 7188            : null;
 189
 114190    public static string? AsString(JsonElement e) => e.ValueKind switch
 114191    {
 113192        JsonValueKind.String => e.GetString(),
 1193        JsonValueKind.Number => e.GetRawText(),
 0194        JsonValueKind.True => "true",
 0195        JsonValueKind.False => "false",
 0196        _ => null
 114197    };
 198
 199    public static string? Str(JsonElement row, string name)
 200    {
 140201        if (!row.TryGetProperty(name, out var v))
 36202            return null;
 104203        var s = AsString(v)?.Trim();
 104204        return string.IsNullOrEmpty(s) ? null : s;
 205    }
 206
 207    public static decimal? Dec(JsonElement row, string name) =>
 30208        decimal.TryParse(Str(row, name), NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : null;
 209
 210    /// <summary>Дата SAP yyyyMMdd; «00000000» и пусто — null.</summary>
 18211    public static DateTime? SapDate(JsonElement row, string name) => ParseSapDate(Str(row, name));
 212
 213    public static DateTime? ParseSapDate(string? s) =>
 18214        DateTime.TryParseExact(s, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d : null;
 215
 6216    private static string StripHtml(string s) => Clean(TagRegex.Replace(s, string.Empty));
 217
 24218    private static string Clean(string s) => SpacesRegex.Replace(HtmlEntity.DeEntitize(s), " ").Trim();
 219}