< Summary

Information
Class: Services.ParserServices.FileProcessing.FileProcessingService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ParserServices/FileProcessing/FileProcessingService.cs
Line coverage
95%
Covered lines: 487
Uncovered lines: 23
Coverable lines: 510
Total lines: 891
Line coverage: 95.4%
Branch coverage
84%
Covered branches: 256
Total branches: 304
Branch coverage: 84.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%
get_Headers()100%11100%
get_Rows()100%11100%
get_Index()100%11100%
get_IsDateType()100%11100%
ProcessFileAsync()100%11100%
ProcessFileAsync()100%22100%
BuildPreviewAsync()95.45%222297.43%
BuildPreviewShipmentOnlyAsync()62.5%161697.67%
ProcessShipmentOnlyAsync()100%22100%
ProcessXlsFileAsync()100%11100%
ProcessExcelFileAsync()100%11100%
ProcessCsvFileAsync()100%11100%
ParseFileAsync()100%66100%
ParseExcelFileAsync()88.88%1818100%
ParseXlsFileAsync()50%4488.23%
BuildParsedDataFromGrid(...)50%6691.66%
DetectHeaderRowIndex(...)75%4480%
RowLooksLikeHeader(...)80%101092.85%
ParseCsvFileAsync()94.44%181896.66%
ExtractOrders(...)100%2424100%
HasShipmentOnlyValues(...)100%11100%
ExtractShipments(...)100%2424100%
BuildDateGroupsShipmentOnly(...)50%44100%
BuildPreviewRowsShipmentOnly(...)58.33%121295.45%
FindDateColumnIndexes(...)95%2020100%
BuildPreviewRows(...)88.88%1818100%
BuildDateGroups(...)75%44100%
FindOrderNumberColumnIndex(...)100%11100%
FindShipmentNumberColumnIndex(...)100%66100%
FindArticleColumnIndex(...)100%44100%
FindPreferredDateColumnIndex(...)100%66100%
FindColumnIndex(...)83.33%6685.71%
HasRequiredValues(...)100%44100%
GetOrderNumberValue(...)90%101085.71%
NormalizeHeader(...)50%22100%
TryNormalizeDate(...)87.5%8882.6%
NormalizeImportedCellValue(...)65%242078.57%
ParseToRussianDateTimeString(...)0%620%
TryParsePreferredDate(...)100%1212100%
TryParseAmbiguousSlashDate(...)65%202092.59%
IsValidOaDate(...)50%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ParserServices/FileProcessing/FileProcessingService.cs

#LineLine coverage
 1using System.Globalization;
 2using CsvHelper;
 3using CsvHelper.Configuration;
 4using ExcelDataReader;
 5using Microsoft.AspNetCore.Http;
 6using OfficeOpenXml;
 7using System.Data;
 8
 9namespace Services.ParserServices.FileProcessing;
 10
 11public class FileProcessingService : IFileProcessingService
 12{
 13    private const string PreferredDeliveryDateColumn = "Фактическая дата передачи в доставку";
 114    private static readonly CultureInfo RuCulture = CultureInfo.GetCultureInfo("ru-RU");
 115    private static readonly CultureInfo EnUsCulture = CultureInfo.GetCultureInfo("en-US");
 16    private const string ShipmentNumberHeaderToken = "отправ";
 117    private static readonly string[] SupportedDateFormats =
 118    [
 119        "dd.MM.yyyy",
 120        "d.M.yyyy",
 121        "dd.MM.yy",
 122        "d.M.yy",
 123        "dd.MM.yyyy HH:mm",
 124        "d.M.yyyy H:mm",
 125        "dd.MM.yyyy HH:mm:ss",
 126        "d.M.yyyy H:mm:ss",
 127        "dd/MM/yyyy",
 128        "d/M/yyyy",
 129        "dd/MM/yy",
 130        "d/M/yy",
 131        "dd/MM/yyyy HH:mm",
 132        "d/M/yyyy H:mm",
 133        "dd/MM/yyyy HH:mm:ss",
 134        "d/M/yyyy H:mm:ss",
 135        "yyyy-MM-dd",
 136        "yyyy-M-d",
 137        "yyyy-MM-dd HH:mm",
 138        "yyyy-MM-dd HH:mm:ss",
 139        "MM/dd/yyyy",
 140        "M/d/yyyy",
 141        "MM/dd/yy",
 142        "M/d/yy",
 143        "MM/dd/yyyy HH:mm",
 144        "M/d/yyyy H:mm",
 145        "MM/dd/yyyy HH:mm:ss",
 146        "M/d/yyyy H:mm:ss"
 147    ];
 48
 49    private sealed class ParsedFileData
 50    {
 29351        public required List<string> Headers { get; init; }
 11252        public required List<string[]> Rows { get; init; }
 53    }
 54
 55    private sealed class DateColumnCandidate
 56    {
 6957        public required int Index { get; init; }
 2858        public required bool IsDateType { get; init; }
 59    }
 60
 61    public async Task<List<(string OrderNumber, string Article)>> ProcessFileAsync(IFormFile file)
 62    {
 1263        return await ProcessFileAsync(file, null, null);
 464    }
 65
 66    public async Task<List<(string OrderNumber, string Article)>> ProcessFileAsync(
 67        IFormFile file,
 68        string? dateColumnName,
 69        IReadOnlyCollection<string>? selectedDateValues)
 70    {
 2171        if (file == null) throw new ArgumentNullException(nameof(file));
 72
 1973        var parsed = await ParseFileAsync(file);
 1274        return ExtractOrders(parsed, dateColumnName, selectedDateValues, fallbackToPreferredDateColumn: true);
 1175    }
 76
 77    public async Task<FilePreviewResult> BuildPreviewAsync(IFormFile file, string preferredDateColumnName)
 78    {
 1679        if (file == null) throw new ArgumentNullException(nameof(file));
 80
 1481        var parsed = await ParseFileAsync(file);
 1482        var orderColumnIndex = FindOrderNumberColumnIndex(parsed.Headers);
 1483        var articleColumnIndex = FindArticleColumnIndex(parsed.Headers);
 84
 1485        if (orderColumnIndex == -1 || articleColumnIndex == -1)
 86        {
 287            return new FilePreviewResult
 288            {
 289                HasRequiredColumns = false,
 290                ErrorMessage = "Не найдены необходимые столбцы 'Номер отправления' и 'Артикул'",
 291                OrderNumberColumn = orderColumnIndex >= 0 ? parsed.Headers[orderColumnIndex] : null,
 292                ArticleColumn = articleColumnIndex >= 0 ? parsed.Headers[articleColumnIndex] : null,
 293                SelectedDateColumn = null,
 294                DateColumns = [],
 295                TotalRows = parsed.Rows.Count,
 296                RowsWithRequiredColumns = 0,
 297                PreviewHeaders = parsed.Headers,
 298                PreviewRows = []
 299            };
 100        }
 101
 12102        var candidateDateColumns = FindDateColumnIndexes(parsed.Headers, parsed.Rows);
 12103        var preferredIndex = FindPreferredDateColumnIndex(parsed.Headers, preferredDateColumnName);
 23104        var selectedIndex = preferredIndex >= 0 && candidateDateColumns.Any(x => x.Index == preferredIndex)
 12105            ? preferredIndex
 106            : candidateDateColumns.Select(x => x.Index).FirstOrDefault();
 107
 12108        var dateColumns = candidateDateColumns
 13109            .Select(candidate => new FileDateColumnPreview
 13110            {
 13111                Name = parsed.Headers[candidate.Index],
 13112                IsPreferred = candidate.Index == selectedIndex,
 13113                IsDateType = candidate.IsDateType,
 13114                Groups = BuildDateGroups(parsed, candidate.Index, orderColumnIndex, articleColumnIndex)
 13115            })
 116            .Where(x => x.IsDateType && x.Groups.Count > 0)
 12117            .ToList();
 118
 119        selectedIndex = dateColumns.FindIndex(x => x.IsPreferred);
 12120        if (selectedIndex < 0 && dateColumns.Count > 0)
 121        {
 0122            dateColumns[0].IsPreferred = true;
 123        }
 124
 125        var selectedDateName = dateColumns.FirstOrDefault(x => x.IsPreferred)?.Name;
 12126        var previewDateIndex = FindColumnIndex(parsed.Headers, selectedDateName);
 127
 12128        return new FilePreviewResult
 12129        {
 12130            HasRequiredColumns = true,
 12131            ErrorMessage = dateColumns.Count == 0 ? "Не найден ни один столбец с датами. Выберите другой файл или провер
 12132            OrderNumberColumn = parsed.Headers[orderColumnIndex],
 12133            ArticleColumn = parsed.Headers[articleColumnIndex],
 12134            SelectedDateColumn = selectedDateName,
 12135            DateColumns = dateColumns,
 12136            TotalRows = parsed.Rows.Count,
 44137            RowsWithRequiredColumns = parsed.Rows.Count(row => HasRequiredValues(row, orderColumnIndex, articleColumnInd
 12138            PreviewHeaders = parsed.Headers,
 12139            PreviewRows = BuildPreviewRows(parsed, previewDateIndex, orderColumnIndex, articleColumnIndex)
 12140        };
 14141    }
 142
 143    /// <summary>
 144    /// Предпросмотр файла отчёта возвратов: только столбец «Номер отправления» и даты (без артикула).
 145    /// </summary>
 146    public async Task<FilePreviewResult> BuildPreviewShipmentOnlyAsync(IFormFile file, string preferredDateColumnName)
 147    {
 4148        if (file == null) throw new ArgumentNullException(nameof(file));
 149
 2150        var parsed = await ParseFileAsync(file);
 2151        var orderColumnIndex = FindOrderNumberColumnIndex(parsed.Headers);
 2152        if (orderColumnIndex == -1)
 153        {
 1154            return new FilePreviewResult
 1155            {
 1156                HasRequiredColumns = false,
 1157                ErrorMessage =
 1158                    "Не найден столбец с номером отправления (ожидается заголовок, содержащий «номер» и «отправлен»).",
 1159                OrderNumberColumn = null,
 1160                ArticleColumn = null,
 1161                SelectedDateColumn = null,
 1162                DateColumns = [],
 1163                TotalRows = parsed.Rows.Count,
 1164                RowsWithRequiredColumns = 0,
 1165                PreviewHeaders = parsed.Headers,
 1166                PreviewRows = []
 1167            };
 168        }
 169
 1170        var shipmentFallbackIndex = FindShipmentNumberColumnIndex(parsed.Headers);
 1171        var candidateDateColumns = FindDateColumnIndexes(parsed.Headers, parsed.Rows);
 1172        var preferredIndex = FindPreferredDateColumnIndex(parsed.Headers, preferredDateColumnName);
 2173        var selectedIndex = preferredIndex >= 0 && candidateDateColumns.Any(x => x.Index == preferredIndex)
 1174            ? preferredIndex
 175            : candidateDateColumns.Select(x => x.Index).FirstOrDefault();
 176
 1177        var dateColumns = candidateDateColumns
 1178            .Select(candidate => new FileDateColumnPreview
 1179            {
 1180                Name = parsed.Headers[candidate.Index],
 1181                IsPreferred = candidate.Index == selectedIndex,
 1182                IsDateType = candidate.IsDateType,
 1183                Groups = BuildDateGroupsShipmentOnly(parsed, candidate.Index, orderColumnIndex, shipmentFallbackIndex)
 1184            })
 185            .Where(x => x.IsDateType && x.Groups.Count > 0)
 1186            .ToList();
 187
 188        selectedIndex = dateColumns.FindIndex(x => x.IsPreferred);
 1189        if (selectedIndex < 0 && dateColumns.Count > 0)
 0190            dateColumns[0].IsPreferred = true;
 191
 192        var selectedDateName = dateColumns.FirstOrDefault(x => x.IsPreferred)?.Name;
 1193        var previewDateIndex = FindColumnIndex(parsed.Headers, selectedDateName);
 194
 1195        return new FilePreviewResult
 1196        {
 1197            HasRequiredColumns = true,
 1198            ErrorMessage = dateColumns.Count == 0
 1199                ? "Не найден ни один столбец с датами. Выберите другой файл или проверьте заголовки."
 1200                : null,
 1201            OrderNumberColumn = parsed.Headers[orderColumnIndex],
 1202            ArticleColumn = null,
 1203            SelectedDateColumn = selectedDateName,
 1204            DateColumns = dateColumns,
 1205            TotalRows = parsed.Rows.Count,
 1206            RowsWithRequiredColumns = parsed.Rows.Count(row =>
 4207                HasShipmentOnlyValues(row, orderColumnIndex, shipmentFallbackIndex)),
 1208            PreviewHeaders = parsed.Headers,
 1209            PreviewRows = BuildPreviewRowsShipmentOnly(parsed, previewDateIndex, orderColumnIndex, shipmentFallbackIndex
 1210        };
 2211    }
 212
 213    /// <summary>
 214    /// Извлекает номера отправлений по выбранному столбцу даты (строки могут повторяться).
 215    /// </summary>
 216    public async Task<List<string>> ProcessShipmentOnlyAsync(
 217        IFormFile file,
 218        string? dateColumnName,
 219        IReadOnlyCollection<string>? selectedDateValues)
 220    {
 5221        if (file == null) throw new ArgumentNullException(nameof(file));
 222
 3223        var parsed = await ParseFileAsync(file);
 3224        return ExtractShipments(parsed, dateColumnName, selectedDateValues, fallbackToPreferredDateColumn: true);
 2225    }
 226
 227    /// <summary>
 228    /// Обработка .xls файлов (старый формат Excel) через ExcelDataReader
 229    /// </summary>
 230    public async Task<List<(string OrderNumber, string Article)>> ProcessXlsFileAsync(IFormFile excelFile)
 231    {
 1232        return await ProcessFileAsync(excelFile, null, null);
 1233    }
 234
 235    /// <summary>
 236    /// Обработка .xlsx файлов (новый формат Excel) через EPPlus
 237    /// </summary>
 238    public async Task<List<(string OrderNumber, string Article)>> ProcessExcelFileAsync(IFormFile excelFile)
 239    {
 3240        return await ProcessFileAsync(excelFile, null, null);
 2241    }
 242
 243    public async Task<List<(string OrderNumber, string Article)>> ProcessCsvFileAsync(IFormFile csvFile)
 244    {
 2245        return await ProcessFileAsync(csvFile, null, null);
 2246    }
 247
 248    private async Task<ParsedFileData> ParseFileAsync(IFormFile file)
 249    {
 38250        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
 38251        return ext switch
 38252        {
 27253            ".csv" => await ParseCsvFileAsync(file),
 3254            ".xlsx" => await ParseExcelFileAsync(file),
 1255            ".xls" => await ParseXlsFileAsync(file),
 4256            _ => throw new Exception("Неподдерживаемый формат файла")
 38257        };
 31258    }
 259
 260    private async Task<ParsedFileData> ParseExcelFileAsync(IFormFile excelFile)
 261    {
 4262        using var stream = new MemoryStream();
 4263        await excelFile.CopyToAsync(stream);
 4264        ExcelPackage.LicenseContext = LicenseContext.NonCommercial;
 265
 4266        using var package = new ExcelPackage(stream);
 4267        var worksheet = package.Workbook.Worksheets.FirstOrDefault();
 4268        var rowCount = worksheet?.Dimension?.Rows ?? 0;
 4269        var colCount = worksheet?.Dimension?.Columns ?? 0;
 270
 4271        if (worksheet == null || rowCount == 0 || colCount == 0)
 1272            throw new Exception("Файл не содержит данных");
 273
 3274        var gridRows = new List<string[]>();
 26275        for (int row = 1; row <= rowCount; row++)
 276        {
 10277            var values = new string[colCount];
 80278            for (int col = 1; col <= colCount; col++)
 279            {
 30280                values[col - 1] = NormalizeImportedCellValue(worksheet.Cells[row, col].Value, worksheet.Cells[row, col].
 281            }
 282
 10283            gridRows.Add(values);
 284        }
 285
 3286        return BuildParsedDataFromGrid(gridRows);
 3287    }
 288
 289    private async Task<ParsedFileData> ParseXlsFileAsync(IFormFile excelFile)
 290    {
 1291        System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
 292
 1293        using var stream = new MemoryStream();
 1294        await excelFile.CopyToAsync(stream);
 1295        stream.Position = 0;
 296
 1297        using var reader = ExcelReaderFactory.CreateReader(stream);
 1298        var result = reader.AsDataSet(new ExcelDataSetConfiguration
 1299        {
 300            ConfigureDataTable = _ => new ExcelDataTableConfiguration { UseHeaderRow = false }
 1301        });
 302
 1303        if (result.Tables.Count == 0)
 0304            throw new Exception("Файл не содержит данных");
 305
 1306        var table = result.Tables[0];
 1307        if (table.Rows.Count == 0)
 0308            throw new Exception("Файл не содержит данных");
 309
 1310        var gridRows = table.Rows.Cast<DataRow>()
 3311            .Select(row => table.Columns.Cast<DataColumn>()
 9312                .Select(column => NormalizeImportedCellValue(row[column], row[column]?.ToString()))
 3313                .ToArray())
 1314            .ToList();
 315
 1316        return BuildParsedDataFromGrid(gridRows);
 1317    }
 318
 319    private static ParsedFileData BuildParsedDataFromGrid(List<string[]> gridRows)
 320    {
 4321        if (gridRows.Count == 0)
 0322            throw new Exception("Файл не содержит данных");
 323
 4324        var headerRowIndex = DetectHeaderRowIndex(gridRows);
 4325        var headers = gridRows[headerRowIndex]
 12326            .Select(x => (x ?? string.Empty).Trim())
 4327            .ToList();
 4328        var rows = gridRows
 4329            .Skip(headerRowIndex + 1)
 14330            .Where(row => row.Any(cell => !string.IsNullOrWhiteSpace(cell)))
 28331            .Select(row => row.Select(cell => (cell ?? string.Empty).Trim()).ToArray())
 4332            .ToList();
 333
 4334        return new ParsedFileData { Headers = headers, Rows = rows };
 335    }
 336
 337    private static int DetectHeaderRowIndex(IReadOnlyList<string[]> gridRows)
 338    {
 4339        var probeCount = Math.Min(30, gridRows.Count);
 12340        for (int i = 0; i < probeCount; i++)
 341        {
 6342            if (RowLooksLikeHeader(gridRows[i]))
 4343                return i;
 344        }
 345
 0346        return 0;
 347    }
 348
 349    private static bool RowLooksLikeHeader(IReadOnlyList<string> row)
 350    {
 6351        if (row == null || row.Count == 0)
 0352            return false;
 353
 6354        var normalized = row
 18355            .Where(x => !string.IsNullOrWhiteSpace(x))
 6356            .Select(NormalizeHeader)
 6357            .ToList();
 6358        if (normalized.Count == 0)
 1359            return false;
 360
 5361        var hasShipmentHeader = normalized.Any(h =>
 11362            h.Contains("номер", StringComparison.OrdinalIgnoreCase) &&
 11363            h.Contains(ShipmentNumberHeaderToken, StringComparison.OrdinalIgnoreCase));
 364
 15365        var hasArticleHeader = normalized.Any(h => h.Contains("артикул", StringComparison.OrdinalIgnoreCase));
 19366        var hasDateHeader = normalized.Any(h => h.Contains("дата", StringComparison.OrdinalIgnoreCase));
 367
 5368        return hasShipmentHeader && (hasArticleHeader || hasDateHeader);
 369    }
 370
 371    private async Task<ParsedFileData> ParseCsvFileAsync(IFormFile csvFile)
 372    {
 29373        using var stream = new MemoryStream();
 29374        await csvFile.CopyToAsync(stream);
 29375        stream.Position = 0;
 376
 29377        using var probeReader = new StreamReader(stream, System.Text.Encoding.UTF8, true, leaveOpen: true);
 29378        var headerLine = await probeReader.ReadLineAsync();
 29379        if (string.IsNullOrWhiteSpace(headerLine))
 2380            throw new Exception("CSV файл пустой");
 381
 382        var delimiter = headerLine.Count(ch => ch == ';') >= headerLine.Count(ch => ch == ',') ? ";" : ",";
 27383        stream.Position = 0;
 384
 27385        using var reader = new StreamReader(stream, System.Text.Encoding.UTF8, true, leaveOpen: true);
 27386        var config = new CsvConfiguration(CultureInfo.InvariantCulture)
 27387        {
 27388            Delimiter = delimiter,
 27389            HasHeaderRecord = true,
 27390            TrimOptions = TrimOptions.Trim,
 27391            BadDataFound = null,
 27392            MissingFieldFound = null
 27393        };
 394
 27395        using var csv = new CsvReader(reader, config);
 27396        await csv.ReadAsync();
 27397        csv.ReadHeader();
 27398        if (csv.HeaderRecord == null)
 0399            throw new Exception("CSV файл пустой");
 400
 401        var headers = csv.HeaderRecord.Select(x => (x ?? string.Empty).Trim()).ToList();
 27402        var rows = new List<string[]>();
 108403        while (await csv.ReadAsync())
 404        {
 81405            var values = new string[headers.Count];
 674406            for (int i = 0; i < headers.Count; i++)
 407            {
 256408                values[i] = (csv.GetField(i) ?? string.Empty).Trim();
 409            }
 410
 81411            rows.Add(values);
 412        }
 413
 27414        return new ParsedFileData { Headers = headers, Rows = rows };
 27415    }
 416
 417    private static List<(string OrderNumber, string Article)> ExtractOrders(
 418        ParsedFileData parsed,
 419        string? dateColumnName,
 420        IReadOnlyCollection<string>? selectedDateValues,
 421        bool fallbackToPreferredDateColumn)
 422    {
 12423        var orderColumnIndex = FindOrderNumberColumnIndex(parsed.Headers);
 12424        var shipmentColumnIndex = FindShipmentNumberColumnIndex(parsed.Headers);
 12425        var articleColumnIndex = FindArticleColumnIndex(parsed.Headers);
 12426        if (orderColumnIndex == -1 || articleColumnIndex == -1)
 1427            throw new Exception("Не найдены необходимые столбцы 'Номер отправления' и 'Артикул'");
 428
 11429        var selectedDateKeys = selectedDateValues == null
 11430            ? null
 14431            : new HashSet<string>(selectedDateValues.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIg
 432
 11433        var effectiveDateColumnIndex = FindColumnIndex(parsed.Headers, dateColumnName);
 11434        if (effectiveDateColumnIndex == -1 && fallbackToPreferredDateColumn)
 435        {
 10436            effectiveDateColumnIndex = FindPreferredDateColumnIndex(parsed.Headers, PreferredDeliveryDateColumn);
 437        }
 438
 11439        var processedOrders = new List<(string OrderNumber, string Article)>();
 78440        foreach (var row in parsed.Rows)
 441        {
 28442            if (!HasRequiredValues(row, orderColumnIndex, articleColumnIndex, shipmentColumnIndex))
 443                continue;
 444
 23445            if (effectiveDateColumnIndex >= 0)
 446            {
 7447                if (!TryNormalizeDate(row[effectiveDateColumnIndex], out var dateKey, out _))
 448                    continue;
 449
 7450                if (selectedDateKeys is { Count: > 0 } && !selectedDateKeys.Contains(dateKey))
 451                    continue;
 452            }
 453
 20454            var orderValue = GetOrderNumberValue(row, orderColumnIndex, shipmentColumnIndex);
 20455            if (string.IsNullOrWhiteSpace(orderValue))
 456                continue;
 457
 20458            processedOrders.Add((orderValue.Trim(), row[articleColumnIndex].Trim()));
 459        }
 460
 11461        return processedOrders;
 462    }
 463
 464    private static bool HasShipmentOnlyValues(IReadOnlyList<string> row, int orderColumnIndex, int fallbackOrderColumnIn
 465    {
 19466        var orderValue = GetOrderNumberValue(row, orderColumnIndex, fallbackOrderColumnIndex);
 19467        return !string.IsNullOrWhiteSpace(orderValue);
 468    }
 469
 470    private static List<string> ExtractShipments(
 471        ParsedFileData parsed,
 472        string? dateColumnName,
 473        IReadOnlyCollection<string>? selectedDateValues,
 474        bool fallbackToPreferredDateColumn)
 475    {
 3476        var orderColumnIndex = FindOrderNumberColumnIndex(parsed.Headers);
 3477        var shipmentColumnIndex = FindShipmentNumberColumnIndex(parsed.Headers);
 3478        if (orderColumnIndex == -1)
 1479            throw new Exception("Не найден столбец с номером отправления");
 480
 2481        var selectedDateKeys = selectedDateValues == null
 2482            ? null
 3483            : new HashSet<string>(selectedDateValues.Where(x => !string.IsNullOrWhiteSpace(x)), StringComparer.OrdinalIg
 484
 2485        var effectiveDateColumnIndex = FindColumnIndex(parsed.Headers, dateColumnName);
 2486        if (effectiveDateColumnIndex == -1 && fallbackToPreferredDateColumn)
 1487            effectiveDateColumnIndex = FindPreferredDateColumnIndex(parsed.Headers, PreferredDeliveryDateColumn);
 488
 2489        var list = new List<string>();
 18490        foreach (var row in parsed.Rows)
 491        {
 7492            if (!HasShipmentOnlyValues(row, orderColumnIndex, shipmentColumnIndex))
 493                continue;
 494
 6495            if (effectiveDateColumnIndex >= 0)
 496            {
 3497                if (effectiveDateColumnIndex >= row.Length ||
 3498                    !TryNormalizeDate(row[effectiveDateColumnIndex], out var dateKey, out _))
 499                    continue;
 500
 3501                if (selectedDateKeys is { Count: > 0 } && !selectedDateKeys.Contains(dateKey))
 502                    continue;
 503            }
 504
 4505            var orderValue = GetOrderNumberValue(row, orderColumnIndex, shipmentColumnIndex);
 4506            if (string.IsNullOrWhiteSpace(orderValue))
 507                continue;
 508
 4509            list.Add(orderValue.Trim());
 510        }
 511
 2512        return list;
 513    }
 514
 515    private static List<FileDatePreviewGroup> BuildDateGroupsShipmentOnly(
 516        ParsedFileData parsed,
 517        int dateColumnIndex,
 518        int orderColumnIndex,
 519        int shipmentFallbackIndex)
 520    {
 1521        var groups = parsed.Rows
 4522            .Where(row => HasShipmentOnlyValues(row, orderColumnIndex, shipmentFallbackIndex))
 3523            .Select(row => dateColumnIndex < row.Length ? row[dateColumnIndex] : string.Empty)
 3524            .Select(value => TryNormalizeDate(value, out var key, out var label) ? new { key, label } : null)
 3525            .Where(x => x != null)
 3526            .GroupBy(x => x!.key)
 2527            .OrderBy(group => group.Key)
 2528            .Select(group => new FileDatePreviewGroup
 2529            {
 2530                Value = group.Key,
 2531                Label = group.First()!.label,
 2532                Count = group.Count()
 2533            })
 1534            .ToList();
 535
 1536        return groups;
 537    }
 538
 539    private static List<FilePreviewTableRow> BuildPreviewRowsShipmentOnly(
 540        ParsedFileData parsed,
 541        int dateColumnIndex,
 542        int orderColumnIndex,
 543        int shipmentFallbackIndex)
 544    {
 1545        if (dateColumnIndex < 0)
 0546            return new List<FilePreviewTableRow>();
 547
 1548        return parsed.Rows
 4549            .Select(row => new
 4550            {
 4551                Row = row,
 4552                Date = dateColumnIndex < row.Length && TryNormalizeDate(row[dateColumnIndex], out var key, out _)
 4553                    ? key
 4554                    : null,
 4555                HasRequired = HasShipmentOnlyValues(row, orderColumnIndex, shipmentFallbackIndex)
 4556            })
 4557            .Select(x => new FilePreviewTableRow
 4558            {
 4559                DateKey = x.Date,
 4560                HasRequiredValues = x.HasRequired,
 4561                OrderNumber = x.HasRequired
 4562                    ? GetOrderNumberValue(x.Row, orderColumnIndex, shipmentFallbackIndex)?.Trim()
 4563                    : null,
 4564                Article = null,
 12565                Values = parsed.Headers.Select((_, index) => index < x.Row.Length ? x.Row[index] : string.Empty).ToList(
 4566            })
 1567            .ToList();
 568    }
 569
 570    private static List<DateColumnCandidate> FindDateColumnIndexes(IReadOnlyList<string> headers, IReadOnlyList<string[]
 571    {
 13572        var result = new List<DateColumnCandidate>();
 110573        for (int i = 0; i < headers.Count; i++)
 574        {
 42575            var header = headers[i];
 42576            var hasDateHeader = NormalizeHeader(header).Contains("дата", StringComparison.OrdinalIgnoreCase);
 42577            var nonEmpty = 0;
 42578            var parsedCount = 0;
 579
 398580            foreach (var row in rows.Take(200))
 581            {
 157582                if (i >= row.Length || string.IsNullOrWhiteSpace(row[i]))
 583                    continue;
 584
 153585                nonEmpty++;
 153586                if (TryNormalizeDate(row[i], out _, out _))
 52587                    parsedCount++;
 588            }
 589
 42590            if (nonEmpty == 0)
 591                continue;
 592
 42593            var ratio = nonEmpty == 0 ? 0 : (double)parsedCount / nonEmpty;
 42594            var isDateType = parsedCount >= Math.Min(5, nonEmpty) && ratio >= 0.6;
 42595            if ((hasDateHeader && parsedCount > 0) || isDateType)
 596            {
 14597                result.Add(new DateColumnCandidate
 14598                {
 14599                    Index = i,
 14600                    IsDateType = isDateType
 14601                });
 602            }
 603        }
 604
 13605        return result;
 606    }
 607
 608    private static List<FilePreviewTableRow> BuildPreviewRows(
 609        ParsedFileData parsed,
 610        int dateColumnIndex,
 611        int orderColumnIndex,
 612        int articleColumnIndex)
 613    {
 12614        if (dateColumnIndex < 0)
 1615            return new List<FilePreviewTableRow>();
 616
 11617        var shipmentColumnIndex = FindShipmentNumberColumnIndex(parsed.Headers);
 618
 11619        return parsed.Rows
 41620            .Select(row => new
 41621            {
 41622                Row = row,
 41623                Date = dateColumnIndex >= 0 && dateColumnIndex < row.Length && TryNormalizeDate(row[dateColumnIndex], ou
 41624                HasRequired = HasRequiredValues(row, orderColumnIndex, articleColumnIndex, shipmentColumnIndex)
 41625            })
 41626            .Select(x => new FilePreviewTableRow
 41627            {
 41628                DateKey = x.Date,
 41629                HasRequiredValues = x.HasRequired,
 41630                OrderNumber = x.HasRequired ? GetOrderNumberValue(x.Row, orderColumnIndex, shipmentColumnIndex)?.Trim() 
 41631                Article = x.HasRequired && articleColumnIndex < x.Row.Length ? x.Row[articleColumnIndex].Trim() : null,
 133632                Values = parsed.Headers.Select((_, index) => index < x.Row.Length ? x.Row[index] : string.Empty).ToList(
 41633            })
 11634            .ToList();
 635    }
 636
 637    private static List<FileDatePreviewGroup> BuildDateGroups(ParsedFileData parsed, int dateColumnIndex, int orderColum
 638    {
 13639        var shipmentColumnIndex = FindShipmentNumberColumnIndex(parsed.Headers);
 640
 13641        var groups = parsed.Rows
 51642            .Where(row => HasRequiredValues(row, orderColumnIndex, articleColumnIndex, shipmentColumnIndex))
 49643            .Select(row => dateColumnIndex < row.Length ? row[dateColumnIndex] : string.Empty)
 49644            .Select(value => TryNormalizeDate(value, out var key, out var label) ? new { key, label } : null)
 49645            .Where(x => x != null)
 46646            .GroupBy(x => x!.key)
 22647            .OrderBy(group => group.Key)
 22648            .Select(group => new FileDatePreviewGroup
 22649            {
 22650                Value = group.Key,
 22651                Label = group.First()!.label,
 22652                Count = group.Count()
 22653            })
 13654            .ToList();
 655
 13656        return groups;
 657    }
 658
 659    private static int FindOrderNumberColumnIndex(IReadOnlyList<string> headers)
 660    {
 31661        return FindShipmentNumberColumnIndex(headers);
 662    }
 663
 664    private static int FindShipmentNumberColumnIndex(IReadOnlyList<string> headers)
 665    {
 178666        for (int i = 0; i < headers.Count; i++)
 667        {
 83668            var normalized = NormalizeHeader(headers[i]);
 83669            if (normalized.Contains("номер") && normalized.Contains(ShipmentNumberHeaderToken))
 65670                return i;
 671        }
 672
 6673        return -1;
 674    }
 675
 676    private static int FindArticleColumnIndex(IReadOnlyList<string> headers)
 677    {
 112678        for (int i = 0; i < headers.Count; i++)
 679        {
 54680            if (NormalizeHeader(headers[i]).Contains("артикул"))
 24681                return i;
 682        }
 683
 2684        return -1;
 685    }
 686
 687    private static int FindPreferredDateColumnIndex(IReadOnlyList<string> headers, string preferredDateColumnName)
 688    {
 24689        var normalizedPreferred = NormalizeHeader(preferredDateColumnName);
 174690        for (int i = 0; i < headers.Count; i++)
 691        {
 75692            var normalizedHeader = NormalizeHeader(headers[i]);
 75693            if (normalizedHeader.Equals(normalizedPreferred, StringComparison.OrdinalIgnoreCase) ||
 75694                normalizedHeader.Contains(normalizedPreferred, StringComparison.OrdinalIgnoreCase))
 12695                return i;
 696        }
 697
 12698        return -1;
 699    }
 700
 701    private static int FindColumnIndex(IReadOnlyList<string> headers, string? columnName)
 702    {
 26703        if (string.IsNullOrWhiteSpace(columnName))
 12704            return -1;
 705
 14706        var normalized = NormalizeHeader(columnName);
 86707        for (int i = 0; i < headers.Count; i++)
 708        {
 43709            if (NormalizeHeader(headers[i]).Equals(normalized, StringComparison.OrdinalIgnoreCase))
 14710                return i;
 711        }
 712
 0713        return -1;
 714    }
 715
 716    private static bool HasRequiredValues(IReadOnlyList<string> row, int orderColumnIndex, int articleColumnIndex, int f
 717    {
 164718        if (articleColumnIndex >= row.Count || string.IsNullOrWhiteSpace(row[articleColumnIndex]))
 7719            return false;
 720
 157721        var orderValue = GetOrderNumberValue(row, orderColumnIndex, fallbackOrderColumnIndex);
 157722        return !string.IsNullOrWhiteSpace(orderValue);
 723    }
 724
 725    private static string? GetOrderNumberValue(IReadOnlyList<string> row, int primaryOrderColumnIndex, int fallbackOrder
 726    {
 242727        if (primaryOrderColumnIndex < row.Count && !string.IsNullOrWhiteSpace(row[primaryOrderColumnIndex]))
 234728            return row[primaryOrderColumnIndex];
 729
 8730        if (fallbackOrderColumnIndex >= 0 &&
 8731            fallbackOrderColumnIndex < row.Count &&
 8732            !string.IsNullOrWhiteSpace(row[fallbackOrderColumnIndex]))
 0733            return row[fallbackOrderColumnIndex];
 734
 8735        return null;
 736    }
 737
 738    private static string NormalizeHeader(string? value)
 739    {
 349740        return (value ?? string.Empty).Trim().ToLowerInvariant();
 741    }
 742
 743    private static bool TryNormalizeDate(string? value, out string key, out string label)
 744    {
 297745        key = string.Empty;
 297746        label = string.Empty;
 747
 297748        if (string.IsNullOrWhiteSpace(value))
 6749            return false;
 750
 291751        var trimmed = value.Trim();
 752
 291753        if (TryParsePreferredDate(trimmed, out var exactRuDate))
 754        {
 158755            var date = exactRuDate.Date;
 158756            key = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
 158757            label = date.ToString("dd.MM.yyyy", RuCulture);
 158758            return true;
 759        }
 760
 133761        var cultures = new[]
 133762        {
 133763            RuCulture,
 133764            EnUsCulture,
 133765            CultureInfo.InvariantCulture
 133766        };
 767
 1064768        foreach (var culture in cultures)
 769        {
 399770            if (DateTime.TryParse(trimmed, culture, DateTimeStyles.AllowWhiteSpaces, out var parsed))
 771            {
 0772                var date = parsed.Date;
 0773                key = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
 0774                label = date.ToString("dd.MM.yyyy", RuCulture);
 0775                return true;
 776            }
 777        }
 778
 133779        return false;
 780    }
 781
 782    private static string NormalizeImportedCellValue(object? rawValue, string? displayedText)
 783    {
 39784        if (rawValue is DateTime dateTime)
 785        {
 0786            return dateTime.ToString("dd.MM.yyyy HH:mm:ss", RuCulture);
 787        }
 788
 39789        if (rawValue is double oaDate && IsValidOaDate(oaDate))
 790        {
 2791            return DateTime.FromOADate(oaDate).ToString("dd.MM.yyyy HH:mm:ss", RuCulture);
 792        }
 793
 37794        if (rawValue is float oaDateFloat && IsValidOaDate(oaDateFloat))
 795        {
 0796            return DateTime.FromOADate(oaDateFloat).ToString("dd.MM.yyyy HH:mm:ss", RuCulture);
 797        }
 798
 37799        if (rawValue is decimal oaDateDecimal && IsValidOaDate((double)oaDateDecimal))
 800        {
 0801            return DateTime.FromOADate((double)oaDateDecimal).ToString("dd.MM.yyyy HH:mm:ss", RuCulture);
 802        }
 803
 37804        var trimmedText = (displayedText ?? string.Empty).Trim();
 37805        if (TryNormalizeDate(trimmedText, out _, out var label))
 806        {
 5807            return trimmedText.Contains(':')
 5808                ? ParseToRussianDateTimeString(trimmedText)
 5809                : label;
 810        }
 811
 32812        return trimmedText;
 813    }
 814
 815    private static string ParseToRussianDateTimeString(string value)
 816    {
 0817        if (TryParsePreferredDate(value, out var parsed))
 818        {
 0819            return parsed.ToString("dd.MM.yyyy HH:mm:ss", RuCulture);
 820        }
 821
 0822        return value.Trim();
 823    }
 824
 825    private static bool TryParsePreferredDate(string value, out DateTime parsed)
 826    {
 291827        var trimmed = value.Trim();
 828
 291829        if (TryParseAmbiguousSlashDate(trimmed, out parsed))
 830        {
 3831            return true;
 832        }
 833
 288834        return DateTime.TryParseExact(trimmed, SupportedDateFormats, RuCulture, DateTimeStyles.AllowWhiteSpaces, out par
 288835               DateTime.TryParseExact(trimmed, SupportedDateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowW
 288836               DateTime.TryParseExact(trimmed, SupportedDateFormats, EnUsCulture, DateTimeStyles.AllowWhiteSpaces, out p
 288837               DateTime.TryParse(trimmed, RuCulture, DateTimeStyles.AllowWhiteSpaces, out parsed) ||
 288838               DateTime.TryParse(trimmed, EnUsCulture, DateTimeStyles.AllowWhiteSpaces, out parsed) ||
 288839               DateTime.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out parsed);
 840    }
 841
 842    private static bool TryParseAmbiguousSlashDate(string value, out DateTime parsed)
 843    {
 291844        parsed = default;
 845
 291846        var datePart = value.Split(' ', 'T')[0];
 291847        var separators = new[] { '/', '-' };
 291848        if (!datePart.Contains('/') || datePart.Contains('.'))
 849        {
 285850            return false;
 851        }
 852
 6853        var parts = datePart.Split(separators, StringSplitOptions.RemoveEmptyEntries);
 6854        if (parts.Length != 3)
 855        {
 0856            return false;
 857        }
 858
 6859        if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var first) ||
 6860            !int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var second))
 861        {
 0862            return false;
 863        }
 864
 6865        var isAmbiguousDayMonth = first is >= 1 and <= 12 && second is >= 1 and <= 12;
 6866        if (!isAmbiguousDayMonth)
 867        {
 3868            return false;
 869        }
 870
 3871        var preferredFormats = new[]
 3872        {
 3873            "dd/MM/yyyy",
 3874            "d/M/yyyy",
 3875            "dd/MM/yy",
 3876            "d/M/yy",
 3877            "dd/MM/yyyy HH:mm",
 3878            "d/M/yyyy H:mm",
 3879            "dd/MM/yyyy HH:mm:ss",
 3880            "d/M/yyyy H:mm:ss"
 3881        };
 882
 3883        return DateTime.TryParseExact(value, preferredFormats, RuCulture, DateTimeStyles.AllowWhiteSpaces, out parsed) |
 3884               DateTime.TryParseExact(value, preferredFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSp
 885    }
 886
 887    private static bool IsValidOaDate(double value)
 888    {
 2889        return value > 0 && value < 2958465;
 890    }
 891}

Methods/Properties

.cctor()
get_Headers()
get_Rows()
get_Index()
get_IsDateType()
ProcessFileAsync()
ProcessFileAsync()
BuildPreviewAsync()
BuildPreviewShipmentOnlyAsync()
ProcessShipmentOnlyAsync()
ProcessXlsFileAsync()
ProcessExcelFileAsync()
ProcessCsvFileAsync()
ParseFileAsync()
ParseExcelFileAsync()
ParseXlsFileAsync()
BuildParsedDataFromGrid(System.Collections.Generic.List`1<System.String[]>)
DetectHeaderRowIndex(System.Collections.Generic.IReadOnlyList`1<System.String[]>)
RowLooksLikeHeader(System.Collections.Generic.IReadOnlyList`1<System.String>)
ParseCsvFileAsync()
ExtractOrders(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.String,System.Collections.Generic.IReadOnlyCollection`1<System.String>,System.Boolean)
HasShipmentOnlyValues(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Int32,System.Int32)
ExtractShipments(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.String,System.Collections.Generic.IReadOnlyCollection`1<System.String>,System.Boolean)
BuildDateGroupsShipmentOnly(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.Int32,System.Int32,System.Int32)
BuildPreviewRowsShipmentOnly(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.Int32,System.Int32,System.Int32)
FindDateColumnIndexes(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String[]>)
BuildPreviewRows(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.Int32,System.Int32,System.Int32)
BuildDateGroups(Services.ParserServices.FileProcessing.FileProcessingService/ParsedFileData,System.Int32,System.Int32,System.Int32)
FindOrderNumberColumnIndex(System.Collections.Generic.IReadOnlyList`1<System.String>)
FindShipmentNumberColumnIndex(System.Collections.Generic.IReadOnlyList`1<System.String>)
FindArticleColumnIndex(System.Collections.Generic.IReadOnlyList`1<System.String>)
FindPreferredDateColumnIndex(System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
FindColumnIndex(System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
HasRequiredValues(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Int32,System.Int32,System.Int32)
GetOrderNumberValue(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Int32,System.Int32)
NormalizeHeader(System.String)
TryNormalizeDate(System.String,System.String&,System.String&)
NormalizeImportedCellValue(System.Object,System.String)
ParseToRussianDateTimeString(System.String)
TryParsePreferredDate(System.String,System.DateTime&)
TryParseAmbiguousSlashDate(System.String,System.DateTime&)
IsValidOaDate(System.Double)