< Summary

Information
Class: Services.ArmtekEtpServices.ArmtekSettlementsExcelReader
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ArmtekEtpServices/ArmtekSettlementsExcelReader.cs
Line coverage
90%
Covered lines: 106
Uncovered lines: 11
Coverable lines: 117
Total lines: 229
Line coverage: 90.5%
Branch coverage
80%
Covered branches: 80
Total branches: 100
Branch coverage: 80%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Read(...)79.03%746285.29%
Text()50%22100%
CheckBalanceChain(...)100%1010100%
CellText(...)90%101091.66%
ReadDate(...)70%1010100%
ReadDecimal(...)66.66%66100%
Normalize(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ArmtekEtpServices/ArmtekSettlementsExcelReader.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using ClosedXML.Excel;
 4
 5namespace Services.ArmtekEtpServices;
 6
 7/// <summary>Строка файла «Взаиморасчёты» из кабинета Armtek (Выгрузить файл → Microsoft Office Excel).</summary>
 8public sealed record ArmtekSettlementExcelRow(
 9    int RowNumber,
 10    string Kunnr,
 11    string? ClientName,
 12    string DocTypeName,
 13    string Vbeln,
 14    string? DocNumber,
 15    DateTime DocDate,
 16    string? PaymentMethodName,
 17    string? Currency,
 18    decimal Amount,
 19    decimal ReturnAmount,
 20    decimal PaymentAmount,
 21    decimal Balance,
 22    DateTime? PayUntil,
 23    string? Contract,
 24    IReadOnlyDictionary<string, string?> Raw);
 25
 26public sealed class ArmtekSettlementsExcelResult
 27{
 28    public List<ArmtekSettlementExcelRow> Rows { get; } = new();
 29    public List<string> Errors { get; } = new();
 30    public List<string> Warnings { get; } = new();
 31    public bool IsValid => Errors.Count == 0 && Rows.Count > 0;
 32    public DateTime? PeriodFrom => Rows.Count == 0 ? null : Rows.Min(r => r.DocDate);
 33    public DateTime? PeriodTo => Rows.Count == 0 ? null : Rows.Max(r => r.DocDate);
 34}
 35
 36/// <summary>
 37/// Чтение и проверка xlsx «Взаиморасчётов». Колонки ищутся по заголовку в первой строке (порядок не важен), обязательны
 38/// <see cref="RequiredColumns"/>. Любая ошибка в строке отклоняет весь файл: частичный импорт взаиморасчётов исказил бы
 39/// Разрыв цепочки сальдо (сальдо = предыдущее − продажа + возврат + платёж) — только предупреждение: строки одного дня
 40/// кабинет может выгрузить в другом порядке.
 41/// </summary>
 42public static class ArmtekSettlementsExcelReader
 43{
 44    public const int MaxRows = 50_000;
 45    private const int MaxErrors = 50;
 46
 147    public static readonly IReadOnlyList<string> RequiredColumns =
 148        ["Покупатель", "Вид документа", "Код документа", "Дата документа", "Сумма продажи", "Сумма возврата", "Сумма пла
 49
 150    public static readonly IReadOnlyList<string> OptionalColumns =
 151        ["Наименование покупателя", "Номер документа", "Способ платежа", "Валюта", "Оплатить до", "Договор"];
 52
 153    private static readonly Regex Digits = new(@"^\d{1,12}$", RegexOptions.Compiled);
 154    private static readonly string[] DateFormats = ["dd.MM.yyyy", "d.M.yyyy", "dd.MM.yy", "yyyy-MM-dd", "yyyyMMdd"];
 55
 56    public static ArmtekSettlementsExcelResult Read(Stream stream)
 57    {
 558        var result = new ArmtekSettlementsExcelResult();
 59        XLWorkbook workbook;
 60        try
 61        {
 562            workbook = new XLWorkbook(stream);
 563        }
 064        catch (Exception ex)
 65        {
 066            result.Errors.Add("Файл не читается как Excel (.xlsx): " + ex.Message);
 067            return result;
 68        }
 69
 570        using (workbook)
 71        {
 572            var sheet = workbook.Worksheets.FirstOrDefault();
 573            var used = sheet?.RangeUsed();
 574            if (sheet == null || used == null)
 75            {
 076                result.Errors.Add("В файле нет данных.");
 077                return result;
 78            }
 79
 580            var headerRow = used.FirstRow().RowNumber();
 581            var columns = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 12682            foreach (var cell in sheet.Row(headerRow).CellsUsed())
 83            {
 5884                var title = Normalize(cell.GetString());
 5885                if (title.Length == 0) continue;
 5886                if (!columns.TryAdd(title, cell.Address.ColumnNumber))
 087                    result.Errors.Add($"Колонка «{title}» встречается в заголовке дважды.");
 5888                else if (!RequiredColumns.Contains(title, StringComparer.OrdinalIgnoreCase) && !OptionalColumns.Contains
 289                    result.Warnings.Add($"Колонка «{title}» не используется — сохранится только в исходной строке.");
 90            }
 91
 4592            var missing = RequiredColumns.Where(c => !columns.ContainsKey(c)).ToList();
 593            if (missing.Count > 0)
 94            {
 995                result.Errors.Add("Это не выгрузка «Взаиморасчётов» Armtek: нет колонок " + string.Join(", ", missing.Se
 196                    ". Нужен файл из кабинета: Финансы → Взаиморасчёты → Выгрузить файл → Microsoft Office Excel (.xlsx)
 197                return result;
 98            }
 99
 4100            var lastRow = used.LastRow().RowNumber();
 4101            if (lastRow - headerRow > MaxRows)
 102            {
 0103                result.Errors.Add($"Слишком много строк: {lastRow - headerRow}, допускается до {MaxRows}.");
 0104                return result;
 105            }
 106
 4107            var seen = new Dictionary<(string, string), int>();
 34108            for (var r = headerRow + 1; r <= lastRow; r++)
 109            {
 13110                var row = sheet.Row(r);
 26111                if (row.CellsUsed().All(c => string.IsNullOrWhiteSpace(c.GetString())))
 112                    continue;
 113
 13114                var errors = new List<string>();
 96115                string? Text(string col) => columns.TryGetValue(col, out var i) ? CellText(row.Cell(i)) : null;
 116
 13117                var kunnr = Text("Покупатель");
 13118                if (kunnr == null || !Digits.IsMatch(kunnr)) errors.Add($"«Покупатель» должен быть кодом из цифр, а не «
 13119                var vbeln = Text("Код документа");
 14120                if (vbeln == null || !Digits.IsMatch(vbeln)) errors.Add($"«Код документа» должен быть кодом из цифр, а н
 13121                var docType = Text("Вид документа");
 13122                if (string.IsNullOrWhiteSpace(docType)) errors.Add("пустой «Вид документа»");
 123
 13124                var docDate = ReadDate(row.Cell(columns["Дата документа"]), "Дата документа", errors, required: true);
 13125                var payUntil = columns.TryGetValue("Оплатить до", out var payCol) ? ReadDate(row.Cell(payCol), "Оплатить
 13126                var amount = ReadDecimal(row.Cell(columns["Сумма продажи"]), "Сумма продажи", errors);
 13127                var returnAmount = ReadDecimal(row.Cell(columns["Сумма возврата"]), "Сумма возврата", errors);
 13128                var paymentAmount = ReadDecimal(row.Cell(columns["Сумма платежа"]), "Сумма платежа", errors);
 13129                var balance = ReadDecimal(row.Cell(columns["Сальдо"]), "Сальдо", errors);
 130
 13131                var currency = Text("Валюта");
 14132                if (currency != null && !Regex.IsMatch(currency, "^[A-Za-z]{3}$")) errors.Add($"«Валюта» должна быть код
 133
 13134                if (kunnr != null && vbeln != null)
 135                {
 13136                    if (seen.TryGetValue((kunnr, vbeln), out var firstRow))
 1137                        errors.Add($"документ {vbeln} уже есть в строке {firstRow}");
 138                    else
 12139                        seen[(kunnr, vbeln)] = r;
 140                }
 141
 13142                if (errors.Count > 0)
 143                {
 14144                    foreach (var e in errors)
 10145                        if (result.Errors.Count < MaxErrors) result.Errors.Add($"Строка {r}: {e}.");
 146                    continue;
 147                }
 148
 319149                var raw = columns.ToDictionary(c => c.Key, c => CellText(row.Cell(c.Value)), StringComparer.Ordinal);
 11150                result.Rows.Add(new ArmtekSettlementExcelRow(r, kunnr!, Text("Наименование покупателя"), docType!.Trim()
 11151                    Text("Номер документа"), docDate!.Value, Text("Способ платежа"), currency?.ToUpperInvariant(),
 11152                    amount!.Value, returnAmount!.Value, paymentAmount!.Value, balance!.Value, payUntil, Text("Договор"),
 153            }
 154
 4155            if (result.Errors.Count >= MaxErrors)
 0156                result.Errors.Add($"Показаны первые {MaxErrors} ошибок.");
 4157            if (result.Rows.Count == 0 && result.Errors.Count == 0)
 0158                result.Errors.Add("В файле нет строк с документами.");
 4159            if (result.Errors.Count == 0)
 3160                CheckBalanceChain(result);
 4161            return result;
 162        }
 5163    }
 164
 165    /// <summary>Сальдо после документа = сальдо до него − продажа + возврат + платёж (так считает кабинет).</summary>
 166    private static void CheckBalanceChain(ArmtekSettlementsExcelResult result)
 167    {
 3168        var breaks = new List<int>();
 22169        foreach (var group in result.Rows.GroupBy(x => x.Kunnr))
 170        {
 3171            ArmtekSettlementExcelRow? prev = null;
 26172            foreach (var row in group)
 173            {
 10174                if (prev != null && Math.Abs(prev.Balance - row.Amount + row.ReturnAmount + row.PaymentAmount - row.Bala
 1175                    breaks.Add(row.RowNumber);
 10176                prev = row;
 177            }
 178        }
 3179        if (breaks.Count > 0)
 1180            result.Warnings.Add($"Сальдо не сходится с предыдущей строкой в {breaks.Count} строках (первые: {string.Join
 1181                "Обычно это другой порядок документов одного дня; суммы загружаются как в файле.");
 3182    }
 183
 184    private static string? CellText(IXLCell cell)
 185    {
 277186        var v = cell.Value;
 277187        var text = v.Type switch
 277188        {
 50189            XLDataType.Blank => null,
 277190            // Коды документов Excel хранит числом — без дробной части и экспоненты.
 91191            XLDataType.Number => v.GetNumber() % 1 == 0
 91192                ? v.GetNumber().ToString("0", CultureInfo.InvariantCulture)
 91193                : v.GetNumber().ToString(CultureInfo.InvariantCulture),
 0194            XLDataType.DateTime => v.GetDateTime().ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
 136195            _ => cell.GetString()
 277196        };
 277197        return string.IsNullOrWhiteSpace(text) ? null : text.Trim();
 198    }
 199
 200    private static DateTime? ReadDate(IXLCell cell, string column, List<string> errors, bool required)
 201    {
 26202        var v = cell.Value;
 26203        if (v.Type == XLDataType.DateTime) return v.GetDateTime().Date;
 26204        if (v.Type == XLDataType.Number) return DateTime.FromOADate(v.GetNumber()).Date;
 26205        var text = CellText(cell);
 26206        if (text == null)
 207        {
 13208            if (required) errors.Add($"пустая «{column}»");
 13209            return null;
 210        }
 25211        if (DateTime.TryParseExact(text, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) ret
 1212        errors.Add($"«{column}» не дата: «{text}» (нужно ДД.ММ.ГГГГ)");
 1213        return null;
 214    }
 215
 216    private static decimal? ReadDecimal(IXLCell cell, string column, List<string> errors)
 217    {
 52218        var v = cell.Value;
 103219        if (v.Type == XLDataType.Number) return Math.Round((decimal)v.GetNumber(), 3);
 1220        var text = CellText(cell);
 1221        if (text == null) return 0m;
 1222        var normalized = text.Replace(" ", string.Empty).Replace(" ", string.Empty).Replace(',', '.');
 1223        if (decimal.TryParse(normalized, NumberStyles.Number, CultureInfo.InvariantCulture, out var d)) return d;
 1224        errors.Add($"«{column}» не число: «{text}»");
 1225        return null;
 226    }
 227
 58228    private static string Normalize(string s) => Regex.Replace(s, @"\s+", " ").Trim();
 229}