< Summary

Information
Class: Services.EmailRoutingServices.OrderEmailImportService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/OrderEmailImportService.cs
Line coverage
35%
Covered lines: 60
Uncovered lines: 110
Coverable lines: 170
Total lines: 318
Line coverage: 35.2%
Branch coverage
24%
Covered branches: 16
Total branches: 66
Branch coverage: 24.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%
.ctor(...)100%11100%
TryImportAsync()20.96%18846222.03%
FilterOutExistingAsync()100%210%
Failed(...)100%11100%
StripOrdersPrefix(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/OrderEmailImportService.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using OzonDomains.Models;
 5using OzonRepositories.Context;
 6using OzonRepositories.Data;
 7using Services.DataServices;
 8using Services.ParserServices;
 9using Services.ParserServices.FielParsers;
 10
 11namespace Services.EmailRoutingServices;
 12
 13/// <summary>
 14/// Распознаёт правила Exist/Эмекс по <see cref="EmailRoutingRule.DropboxFolderTemplate"/> (тот же формат имени
 15/// папки, что раньше читал Dropbox-джоб FileUpload — JobController.FileUpload/ParseFileFieldAndAdjustDate) и
 16/// вставляет заказы напрямую через штатный OrderCaster/ColumnMapping, не трогая Dropbox и не включая сам джоб.
 17/// Письма старше <see cref="EmailRoutingExecutionOptions.OrderImportMaxBacklogHours"/> не импортирует —
 18/// см. инцидент 17.07.2026 (backlog после восстановления БД получил ProcessingDate=Now и часть заказов
 19/// перезаписалась чужими данными из-за совпадения Order.Key).
 20/// </summary>
 21public class OrderEmailImportService : IOrderEmailImportService
 22{
 123    private static readonly HashSet<string> SupportedMappingNames =
 124        new(StringComparer.OrdinalIgnoreCase) { "Exist", "Эмекс" };
 25
 26    private readonly ApplicationDbContext _context;
 27    private readonly OrderCaster _orderCaster;
 28    private readonly ExcelParser _excelParser;
 29    private readonly ColumnMappingDataServices _columnMappingDataServices;
 30    private readonly OrdersDataServices _ordersDataServices;
 31    private readonly FileUploadRecordDataService _fileUploadRecordDataService;
 32    private readonly IOrderChangeNotifier _orderChangeNotifier;
 33    private readonly EmailRoutingExecutionOptions _executionOptions;
 34    private readonly ILogger<OrderEmailImportService> _logger;
 35
 536    public OrderEmailImportService(
 537        ApplicationDbContext context,
 538        OrderCaster orderCaster,
 539        ExcelParser excelParser,
 540        ColumnMappingDataServices columnMappingDataServices,
 541        OrdersDataServices ordersDataServices,
 542        FileUploadRecordDataService fileUploadRecordDataService,
 543        IOrderChangeNotifier orderChangeNotifier,
 544        IOptions<EmailRoutingExecutionOptions> executionOptions,
 545        ILogger<OrderEmailImportService> logger)
 46    {
 547        _context = context;
 548        _orderCaster = orderCaster;
 549        _excelParser = excelParser;
 550        _columnMappingDataServices = columnMappingDataServices;
 551        _ordersDataServices = ordersDataServices;
 552        _fileUploadRecordDataService = fileUploadRecordDataService;
 553        _orderChangeNotifier = orderChangeNotifier;
 554        _executionOptions = executionOptions.Value;
 555        _logger = logger;
 556    }
 57
 58    public async Task<OrderImportResult> TryImportAsync(
 59        EmailRoutingRule rule,
 60        InboxMessageDto message,
 61        InboxAttachmentDto attachment,
 62        bool forceReprocess = false,
 63        CancellationToken cancellationToken = default)
 64    {
 565        var folderName = StripOrdersPrefix(rule.DropboxFolderTemplate);
 66
 67        // Пробный разбор только для проверки, что это вообще Exist/Эмекс — дата ещё не важна на этом шаге.
 568        var (mappingName, _, _) = DropboxOrderFolderNaming.ParseFolderNameAndAdjustDate(folderName, DateTime.Now);
 69
 570        if (!SupportedMappingNames.Contains(mappingName))
 71        {
 172            return new OrderImportResult { Handled = false };
 73        }
 74
 475        var isEmeks = string.Equals(mappingName, "Эмекс", StringComparison.OrdinalIgnoreCase);
 76
 77        // Без даты получения не на что опереться при смещении даты отгрузки — этот хард-блок forceReprocess
 78        // не обходит, независимо от возраста.
 479        var messageAge = message.ReceivedAtUtc.HasValue
 480            ? DateTime.UtcNow - message.ReceivedAtUtc.Value
 481            : (TimeSpan?)null;
 482        if (messageAge == null)
 83        {
 284            return Failed(mappingName, string.Empty,
 285                "Не удалось определить дату получения письма — автоимпорт пропущен, " +
 286                "загрузить вручную по .cursor/skills/load-exist-orders/SKILL.md.");
 87        }
 88
 89        // Возраст письма — по времени получения, а не по моменту запуска кода. Без этой проверки автоматический
 90        // polling, повторно обрабатывающий беклог (например после восстановления БД из бэкапа, когда состояние
 91        // "уже обработано" откатилось назад), ставит сегодняшнюю дату на старые заказы и может переписать чужие
 92        // данные при совпадении Order.Key. forceReprocess=true (ручной Reprocess одного письма через UI) этот
 93        // cutoff обходит — человек явно запросил переобработку именно этого письма, а от порчи данных защищает
 94        // не cutoff, а insert-only вставка ниже (совпавшие по Article+ShipmentNumber строки не апдейтятся).
 295        var backlogCutoff = TimeSpan.FromHours(_executionOptions.OrderImportMaxBacklogHours);
 296        if (!forceReprocess && messageAge > backlogCutoff)
 97        {
 198            return Failed(mappingName, string.Empty,
 199                $"Письмо старше {_executionOptions.OrderImportMaxBacklogHours} ч. (получено: " +
 1100                $"{message.ReceivedAtUtc:dd.MM.yyyy HH:mm}) — автоимпорт пропущен, " +
 1101                "загрузить вручную по .cursor/skills/load-exist-orders/SKILL.md.");
 102        }
 103
 104        // Единая точка отсчёта даты письма (не DateTime.Now в момент выполнения кода) — используется и для
 105        // смещения даты отгрузки по имени папки, и как ProcessingDate ниже.
 1106        var referenceDate = message.ReceivedAtUtc!.Value.ToLocalTime();
 1107        var (_, adjustedShippingDate, delivery) =
 1108            DropboxOrderFolderNaming.ParseFolderNameAndAdjustDate(folderName, referenceDate);
 109
 110        try
 111        {
 1112            var mapping = await _columnMappingDataServices.GetColumnMappingAsync(
 1113                new ColumnMapping { MappingName = mappingName });
 114
 1115            if (mapping == null && !isEmeks)
 116            {
 1117                return Failed(mappingName, delivery, $"Не найдено сопоставление столбцов «{mappingName}».");
 118            }
 119
 0120            var selectedClient = await _context.OzonClients.FindAsync(mapping?.SelectedClientId)
 0121                                  ?? new OzonClient { Id = 0 };
 0122            var selectedManufacturer = await _context.Manufacturers.FindAsync(mapping?.SelectedManufacturerId)
 0123                                        ?? new Manufacturer { Id = 0 };
 0124            var selectedWarehouse = await _context.Warehouses.FindAsync(mapping?.SelectedWarehouseId)
 0125                                     ?? new Warehouse { Id = 0 };
 126
 0127            if (selectedWarehouse.Id == 0 && !string.IsNullOrEmpty(delivery))
 128            {
 0129                var deliveryWarehouseMapping = await _context.WarehouseMappings
 0130                    .Include(x => x.OzonClient)
 0131                    .FirstOrDefaultAsync(wm => wm.DeliveryType == delivery, cancellationToken);
 0132                if (deliveryWarehouseMapping != null)
 133                {
 0134                    selectedWarehouse = await _context.Warehouses
 0135                        .FirstOrDefaultAsync(w => w.Name == deliveryWarehouseMapping.OzonWarehouseName, cancellationToke
 0136                        ?? selectedWarehouse;
 0137                    if (selectedClient.Id == 0 && deliveryWarehouseMapping.OzonClient != null)
 138                    {
 0139                        selectedClient = deliveryWarehouseMapping.OzonClient;
 140                    }
 141                }
 0142            }
 143
 0144            var selectedSupplier = await _context.Suppliers.FindAsync(mapping?.SelectedSupplierId)
 0145                                    ?? new Supplier { Id = 0 };
 146
 0147            if (selectedWarehouse.Id == 0)
 148            {
 0149                return Failed(mappingName, delivery,
 0150                    $"Не удалось определить склад отгрузки для доставки «{delivery}» (нет записи в WarehouseMapping).");
 151            }
 152
 0153            DateTime? selectedShippingDate = adjustedShippingDate;
 0154            DateTime? selectedProcessingDate = referenceDate;
 155
 0156            using var memoryStream = new MemoryStream(attachment.FileBytes);
 157            List<Order> orders;
 158
 0159            if (isEmeks)
 160            {
 0161                var xlsData = await _excelParser.GetTableDataFromXlsAsync(
 0162                    memoryStream, startRow: 10, startColumn: 1, dateRow: 5, dateColumn: 7);
 163
 0164                if (xlsData.DocumentDate == null)
 165                {
 0166                    return Failed(mappingName, delivery, "Не удалось прочитать дату документа (ячейка G5) из файла Эмекс
 167                }
 168
 0169                var difference = selectedShippingDate!.Value - selectedProcessingDate!.Value;
 0170                selectedProcessingDate = xlsData.DocumentDate;
 0171                selectedShippingDate = xlsData.DocumentDate.Value.Add(difference);
 172
 0173                orders = await _orderCaster.XlsToOrdersForDropbox(
 0174                    xlsData.TableData, selectedClient, selectedWarehouse,
 0175                    selectedShippingDate.Value, selectedProcessingDate.Value, delivery);
 0176                foreach (var order in orders)
 177                {
 0178                    order.MaxOzonCommission = 0;
 0179                    order.MinOzonCommission = 0;
 180                }
 181            }
 182            else
 183            {
 0184                if (!mapping!.SelectedCurrencyCode.HasValue)
 185                {
 0186                    return Failed(mappingName, delivery, "Не указан код валюты в сопоставлении столбцов.");
 187                }
 188
 0189                var tableData = await _excelParser.GetTableDataAsyncForDropbox(memoryStream, startRow: 1, startColumn: 1
 0190                tableData = _excelParser.UpdateTableToStandartColumns(tableData, mapping.ColumnMappings);
 191
 0192                orders = await _orderCaster.ExcelToOrdersForDropbox(
 0193                    tableData, selectedClient, selectedManufacturer, selectedWarehouse, selectedSupplier,
 0194                    mapping.SelectedStatus ?? string.Empty, mapping.SelectedCurrencyCode.Value,
 0195                    selectedShippingDate, selectedProcessingDate, delivery);
 196            }
 197
 0198            if (orders.Count == 0)
 199            {
 0200                return Failed(mappingName, delivery, "Файл не содержит распознанных строк заказов.");
 201            }
 202
 203            // Файл поставщика не может «обновить» уже существующий заказ — только принести новый.
 204            // Раньше отсюда шли все строки в AddOrders, и он апдейтил совпавшую по (Article, ShipmentNumber)
 205            // строку целиком — это и перезаписало чужие заказы 17.07.2026. Теперь берём только то, чего в базе
 206            // ещё нет; совпавшие строки просто пропускаем, ничего в них не трогаем.
 0207            var newOrders = await FilterOutExistingAsync(orders, cancellationToken);
 0208            var skippedExistingCount = orders.Count - newOrders.Count;
 209
 0210            if (newOrders.Count == 0)
 211            {
 0212                return new OrderImportResult
 0213                {
 0214                    Handled = true,
 0215                    IsSuccess = true,
 0216                    MappingName = mappingName,
 0217                    Delivery = delivery,
 0218                    AddedCount = 0,
 0219                    UpdatedCount = 0,
 0220                    SkippedExistingCount = skippedExistingCount
 0221                };
 222            }
 223
 0224            newOrders = await _ordersDataServices.SetUniqueShipmentNumberAndKey(newOrders);
 0225            newOrders = await _orderCaster.SetFileDataAsync(newOrders, mappingName, attachment.FileName);
 226
 0227            var addedOrderNumbers = new List<string>();
 0228            var uploadResult = await _ordersDataServices.AddOrders(newOrders, addedOrderNumbers);
 229
 230            // Email-импорт должен повторно поставить именно свои новые заказы в ES уже после
 231            // завершения AddOrders (и её транзакции), не меняя общий поток Ozon.
 0232            _orderChangeNotifier.NotifyOrdersChanged(newOrders
 0233                .Where(order => order.Id > 0)
 0234                .Select(order => order.Id));
 235
 0236            var existingFileNames = await _fileUploadRecordDataService.GetExistingFileNamesAsync(new[] { attachment.File
 0237            if (!existingFileNames.Contains(attachment.FileName))
 238            {
 0239                await _fileUploadRecordDataService.AddFileUploadRecord(new FileUploadRecord
 0240                {
 0241                    FileName = attachment.FileName,
 0242                    FolderName = mappingName,
 0243                    Date = DateTime.Now,
 0244                    Result = $"Добавлено {uploadResult[0]}, обновлено {uploadResult[1]} (импорт из письма, без Dropbox)"
 0245                });
 246            }
 247
 0248            return new OrderImportResult
 0249            {
 0250                Handled = true,
 0251                IsSuccess = true,
 0252                MappingName = mappingName,
 0253                Delivery = delivery,
 0254                AddedCount = uploadResult[0],
 0255                UpdatedCount = uploadResult[1],
 0256                SkippedExistingCount = skippedExistingCount,
 0257                AddedOrderNumbers = addedOrderNumbers
 0258            };
 259        }
 0260        catch (Exception ex)
 261        {
 0262            _logger.LogError(ex,
 0263                "Не удалось импортировать заказы из письма для правила {RuleId} ({RuleName}), файл {FileName}",
 0264                rule.Id, rule.Name, attachment.FileName);
 0265            return Failed(mappingName, delivery, ex.Message);
 266        }
 5267    }
 268
 269    /// <summary>
 270    /// Оставляет только строки, которых ещё нет в Orders — по тому же ключу (Article, ShipmentNumber
 271    /// после <see cref="OrdersDataServices.TrimShipmentNumber"/>), которым сверяет совпадения сам
 272    /// <see cref="OrdersDataServices.AddOrders"/>. Совпавшие строки просто отбрасываются, не идут в AddOrders
 273    /// вообще — значит апдейт-ветка там для них никогда не сработает.
 274    /// </summary>
 275    private async Task<List<Order>> FilterOutExistingAsync(List<Order> orders, CancellationToken cancellationToken)
 276    {
 0277        var articles = orders
 0278            .Select(o => o.Article)
 0279            .Where(a => !string.IsNullOrEmpty(a))
 0280            .Distinct()
 0281            .ToList();
 282
 0283        var existing = await _context.Orders
 0284            .Where(o => articles.Contains(o.Article))
 0285            .Select(o => new { o.Article, o.ShipmentNumber })
 0286            .ToListAsync(cancellationToken);
 287
 0288        var existingKeys = existing
 0289            .Select(o => (o.Article, Trimmed: OrdersDataServices.TrimShipmentNumber(o.ShipmentNumber)))
 0290            .ToHashSet();
 291
 0292        return orders
 0293            .Where(o => !existingKeys.Contains((o.Article, OrdersDataServices.TrimShipmentNumber(o.ShipmentNumber))))
 0294            .ToList();
 0295    }
 296
 297    private static OrderImportResult Failed(string mappingName, string delivery, string error) =>
 4298        new()
 4299        {
 4300            Handled = true,
 4301            IsSuccess = false,
 4302            MappingName = mappingName,
 4303            Delivery = delivery,
 4304            Error = error
 4305        };
 306
 307    private static string StripOrdersPrefix(string dropboxFolderTemplate)
 308    {
 5309        var value = (dropboxFolderTemplate ?? string.Empty).Trim().TrimStart('/');
 310        const string prefix = "Orders/";
 5311        if (value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 312        {
 5313            value = value[prefix.Length..];
 314        }
 315
 5316        return value.Trim();
 317    }
 318}