< Summary

Information
Class: Services.BackgroundJobsServices.AutomatedTransactionProcessingService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BackgroundJobsServices/AutomatedTransactionProcessingService.cs
Line coverage
5%
Covered lines: 81
Uncovered lines: 1280
Coverable lines: 1361
Total lines: 2140
Line coverage: 5.9%
Branch coverage
8%
Covered branches: 34
Total branches: 422
Branch coverage: 8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
get_ReadyOrders()100%210%
get_WarehouseByOrderId()100%210%
get_SkippedCount()100%210%
get_FailureDetails()100%210%
IsAlreadyShippedToCommissioner(...)100%22100%
IsStatusOnlyCommissionerChange(...)100%22100%
IsEligibleForAutomatedProcessing(...)100%44100%
.ctor(...)100%210%
ProcessShippedToCommissionerAsync()0%4260%
ProcessShippedToCommissionerManualAsync()100%210%
ProcessShippedToCommissionerScheduledRetryAsync()0%4260%
ProcessShippedToCommissionerManualRetryAsync()100%210%
ProcessCustomerReturnAsync()0%4260%
ProcessCustomerReturnManualAsync()100%210%
ProcessCustomerReturnScheduledRetryAsync()0%4260%
ProcessCustomerReturnManualRetryAsync()100%210%
ProcessCustomerReturnInternalAsync()0%110100%
ProcessShippedToCommissionerInternalAsync()0%600240%
TryNotifyTransactionCompletedAsync()0%2040%
MaybeScheduleRetryAsync()0%272160%
SendLogAsync()0%620%
SendProgressAsync()0%620%
SendCompleteAsync()0%620%
SendErrorAsync()0%620%
BuildReportPeriods(...)0%620%
MatchOrdersInDatabaseAsync()0%210140%
FindOrdersForReportRow(...)100%66100%
FilterEligibleOrdersAsync()0%7280%
ResolveCommissionerSenderWarehousesAsync()0%272160%
BuildWarehouseResolutionFailureMessage(...)100%44100%
TruncateErrorMessage(...)100%44100%
GroupByWarehouse(...)100%44100%
BuildAutomatedCommissionerComment(...)100%22100%
CreateTransactionsAsync()0%812280%
PostTransactionsTo1CAsync()0%1980440%
MatchOrdersByShipmentAsync()0%342180%
ResolveCustomerReturnWarehousesAsync()0%420200%
ApplyCommissionerStatusOnlyAsync()0%210140%
LogCommissionerProcessingSkippedAsync()0%4260%
LogCustomerReturnSkippedAsync()0%4260%
ProcessCustomerReturnWithOneCAsync()0%2970540%
PostCustomerReturnTo1CWithRetryAsync()0%4260%
LogCustomerReturnPostedAsync()0%272160%
LogCustomerReturnFailedAsync()0%2040%
ApplySystemAttribution(...)100%22100%
ClearOrderNavigationForPersist(...)100%22100%
PersistCustomerReturnStatusAsync()0%620%
TryRecordOneCHistoryMovementAsync(...)0%620%
TryRecordStatusTransitionHistoryAsync()0%620%
FormatExceptionDetail(...)100%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BackgroundJobsServices/AutomatedTransactionProcessingService.cs

#LineLine coverage
 1using Hangfire;
 2using Microsoft.AspNetCore.SignalR;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.DependencyInjection;
 5using OzonDomains;
 6using OzonDomains.Models;
 7using OzonRepositories.Context;
 8using Services.ApiServices;
 9using Services.ApiServices._1CApi;
 10using Services.ApiServices._1CApi.Models;
 11using Services.ApiServices._1CApi.OData;
 12using Services.DataServices;
 13using Services.SignalRServices;
 14using Services.HangfireService;
 15using Services.TransactionUtilsServices;
 16
 17namespace Services.BackgroundJobsServices;
 18
 19public interface IAutomatedTransactionProcessingService
 20{
 21    Task ProcessShippedToCommissionerAsync(int scheduleId, CancellationToken cancellationToken = default);
 22    Task ProcessShippedToCommissionerManualAsync(int ozonClientId, int daysBack, string? jobId = null, CancellationToken
 23    Task ProcessShippedToCommissionerScheduledRetryAsync(int scheduleId, int retryAttempt, CancellationToken cancellatio
 24    Task ProcessShippedToCommissionerManualRetryAsync(int ozonClientId, int daysBack, string? jobId, int retryAttempt, C
 25    Task ProcessCustomerReturnAsync(int scheduleId, CancellationToken cancellationToken = default);
 26    Task ProcessCustomerReturnManualAsync(int ozonClientId, int daysBack, string? jobId = null, CancellationToken cancel
 27    Task ProcessCustomerReturnScheduledRetryAsync(int scheduleId, int retryAttempt, CancellationToken cancellationToken 
 28    Task ProcessCustomerReturnManualRetryAsync(int ozonClientId, int daysBack, string? jobId, int retryAttempt, Cancella
 29}
 30
 31public class AutomatedTransactionProcessingService : IAutomatedTransactionProcessingService
 32{
 33    private readonly IBackgroundJobLoggingService _loggingService;
 34    private readonly IBackgroundJobScheduleService _scheduleService;
 35    private readonly ITransactionJobRetryScheduler _retryScheduler;
 36    private readonly IOzonReportLoaderService _reportLoader;
 37    private readonly OrdersDataServices _orderServices;
 38    private readonly TransactionDataServices _transactionDataServices;
 39    private readonly AppStatusDataServices _appStatusServices;
 40    private readonly TransactionManager _transactionManager;
 41    private readonly OneCTransferManager _oneCTransferManager;
 42    private readonly IStatusTransitionHistoryService _statusTransitionHistoryService;
 43    private readonly StatusTransitionDataServices _statusTransitionDataServices;
 44    private readonly WarehouseSelectionResolver _warehouseSelectionResolver;
 45    private readonly IHubContext<JobProgressHub> _hubContext;
 46    private readonly ApplicationDbContext _context;
 47    private readonly IServiceProvider _serviceProvider;
 048    private static readonly TimeZoneInfo RussianTimeZone = RussianTimeZoneHelper.Zone;
 49    private const string SystemUserName = "Система";
 50    private const string CommissionerStatusName = "Отгружен комиссионеру";
 51    private const string CustomerReturnStatusName = "Возврат покупателя";
 52    private const string SellerStatusName = "Отгружен реализатором";
 53    private const string ZasylKey = "Засыл";
 54    private const int OrderProcessingBatchSize = 200;
 55    private const int MaxExecutionErrorMessageLength = 16000;
 56
 57    private sealed class CommissionerWarehouseBatchResult
 58    {
 059        public List<Order> ReadyOrders { get; init; } = [];
 060        public Dictionary<int, string> WarehouseByOrderId { get; init; } = new();
 061        public int SkippedCount { get; init; }
 062        public List<string> FailureDetails { get; init; } = [];
 63    }
 64
 65    internal static bool IsAlreadyShippedToCommissioner(Order order) =>
 466        string.Equals(order.AppStatus?.Name, CommissionerStatusName, StringComparison.OrdinalIgnoreCase);
 67
 68    /// <summary>
 69    /// Как в ручной отгрузке комиссионеру: заказ уже «Отгружен реализатором» — только смена статуса, без документа и 1С
 70    /// </summary>
 71    internal static bool IsStatusOnlyCommissionerChange(Order order) =>
 472        string.Equals(order.AppStatus?.Name, SellerStatusName, StringComparison.OrdinalIgnoreCase);
 73
 74    /// <summary>
 75    /// Определяет, подходит ли заказ для автоматической обработки.
 76    /// Вынесено из FilterEligibleOrdersAsync для изолированного тестирования.
 77    /// </summary>
 78    internal static bool IsEligibleForAutomatedProcessing(Order order) =>
 679        order.AppStatus != null &&
 680        !string.Equals(order.AppStatus.Name, CommissionerStatusName, StringComparison.OrdinalIgnoreCase) &&
 681        !string.Equals(order.AppStatus.Name, "Отменен покупателем", StringComparison.OrdinalIgnoreCase);
 82
 083    public AutomatedTransactionProcessingService(
 084        IBackgroundJobLoggingService loggingService,
 085        IBackgroundJobScheduleService scheduleService,
 086        ITransactionJobRetryScheduler retryScheduler,
 087        IOzonReportLoaderService reportLoader,
 088        OrdersDataServices orderServices,
 089        TransactionDataServices transactionDataServices,
 090        AppStatusDataServices appStatusServices,
 091        TransactionManager transactionManager,
 092        OneCTransferManager oneCTransferManager,
 093        IStatusTransitionHistoryService statusTransitionHistoryService,
 094        StatusTransitionDataServices statusTransitionDataServices,
 095        WarehouseSelectionResolver warehouseSelectionResolver,
 096        IHubContext<JobProgressHub> hubContext,
 097        ApplicationDbContext context,
 098        IServiceProvider serviceProvider)
 99    {
 0100        _loggingService = loggingService;
 0101        _scheduleService = scheduleService;
 0102        _retryScheduler = retryScheduler;
 0103        _reportLoader = reportLoader;
 0104        _orderServices = orderServices;
 0105        _transactionDataServices = transactionDataServices;
 0106        _appStatusServices = appStatusServices;
 0107        _transactionManager = transactionManager;
 0108        _oneCTransferManager = oneCTransferManager;
 0109        _statusTransitionHistoryService = statusTransitionHistoryService;
 0110        _statusTransitionDataServices = statusTransitionDataServices;
 0111        _warehouseSelectionResolver = warehouseSelectionResolver;
 0112        _hubContext = hubContext;
 0113        _context = context;
 0114        _serviceProvider = serviceProvider;
 0115    }
 116
 117    [Queue(HangfireQueueNames.Main)]
 118    public async Task ProcessShippedToCommissionerAsync(int scheduleId, CancellationToken cancellationToken = default)
 119    {
 0120        var schedule = await _scheduleService.GetScheduleByIdAsync(scheduleId, cancellationToken);
 0121        if (schedule == null || !schedule.IsActive)
 122        {
 0123            Console.WriteLine($"[AutomatedTransactionProcessing] Schedule {scheduleId} not found or inactive");
 0124            return;
 125        }
 126
 0127        if (!schedule.OzonClientId.HasValue)
 128        {
 0129            Console.WriteLine($"[AutomatedTransactionProcessing] Schedule {scheduleId} has no OzonClientId");
 0130            return;
 131        }
 132
 0133        schedule.LastRunTime = DateTime.UtcNow;
 0134        await _context.SaveChangesAsync(cancellationToken);
 135
 0136        await ProcessShippedToCommissionerInternalAsync(
 0137            schedule.OzonClientId.Value,
 0138            schedule.DaysBack,
 0139            scheduleId,
 0140            "Scheduled",
 0141            null,
 0142            retryAttempt: 0,
 0143            cancellationToken);
 0144    }
 145
 146    [Queue(HangfireQueueNames.Main)]
 147    public async Task ProcessShippedToCommissionerManualAsync(int ozonClientId, int daysBack, string? jobId = null, Canc
 148    {
 0149        await ProcessShippedToCommissionerInternalAsync(ozonClientId, daysBack, null, "Manual", jobId, 0, cancellationTo
 0150    }
 151
 152    [Queue(HangfireQueueNames.Main)]
 153    public async Task ProcessShippedToCommissionerScheduledRetryAsync(int scheduleId, int retryAttempt, CancellationToke
 154    {
 0155        var schedule = await _scheduleService.GetScheduleByIdAsync(scheduleId, cancellationToken);
 0156        if (schedule == null || !schedule.IsActive || !schedule.OzonClientId.HasValue)
 0157            return;
 158
 0159        schedule.LastRunTime = DateTime.UtcNow;
 0160        await _context.SaveChangesAsync(cancellationToken);
 161
 0162        await ProcessShippedToCommissionerInternalAsync(
 0163            schedule.OzonClientId.Value,
 0164            schedule.DaysBack,
 0165            scheduleId,
 0166            "Retry",
 0167            null,
 0168            retryAttempt,
 0169            cancellationToken);
 0170    }
 171
 172    [Queue(HangfireQueueNames.Main)]
 173    public async Task ProcessShippedToCommissionerManualRetryAsync(
 174        int ozonClientId, int daysBack, string? jobId, int retryAttempt, CancellationToken cancellationToken = default)
 175    {
 0176        await ProcessShippedToCommissionerInternalAsync(ozonClientId, daysBack, null, "Retry", jobId, retryAttempt, canc
 0177    }
 178
 179    [Queue(HangfireQueueNames.Main)]
 180    public async Task ProcessCustomerReturnAsync(int scheduleId, CancellationToken cancellationToken = default)
 181    {
 0182        var schedule = await _scheduleService.GetScheduleByIdAsync(scheduleId, cancellationToken);
 0183        if (schedule == null || !schedule.IsActive)
 0184            return;
 185
 0186        if (!schedule.OzonClientId.HasValue)
 0187            return;
 188
 0189        schedule.LastRunTime = DateTime.UtcNow;
 0190        await _context.SaveChangesAsync(cancellationToken);
 191
 0192        await ProcessCustomerReturnInternalAsync(
 0193            schedule.OzonClientId.Value,
 0194            schedule.DaysBack,
 0195            scheduleId,
 0196            "Scheduled",
 0197            null,
 0198            retryAttempt: 0,
 0199            cancellationToken);
 0200    }
 201
 202    [Queue(HangfireQueueNames.Main)]
 203    public async Task ProcessCustomerReturnManualAsync(int ozonClientId, int daysBack, string? jobId = null, Cancellatio
 204    {
 0205        await ProcessCustomerReturnInternalAsync(ozonClientId, daysBack, null, "Manual", jobId, 0, cancellationToken);
 0206    }
 207
 208    [Queue(HangfireQueueNames.Main)]
 209    public async Task ProcessCustomerReturnScheduledRetryAsync(int scheduleId, int retryAttempt, CancellationToken cance
 210    {
 0211        var schedule = await _scheduleService.GetScheduleByIdAsync(scheduleId, cancellationToken);
 0212        if (schedule == null || !schedule.IsActive || !schedule.OzonClientId.HasValue)
 0213            return;
 214
 0215        schedule.LastRunTime = DateTime.UtcNow;
 0216        await _context.SaveChangesAsync(cancellationToken);
 217
 0218        await ProcessCustomerReturnInternalAsync(
 0219            schedule.OzonClientId.Value,
 0220            schedule.DaysBack,
 0221            scheduleId,
 0222            "Retry",
 0223            null,
 0224            retryAttempt,
 0225            cancellationToken);
 0226    }
 227
 228    [Queue(HangfireQueueNames.Main)]
 229    public async Task ProcessCustomerReturnManualRetryAsync(
 230        int ozonClientId, int daysBack, string? jobId, int retryAttempt, CancellationToken cancellationToken = default)
 231    {
 0232        await ProcessCustomerReturnInternalAsync(ozonClientId, daysBack, null, "Retry", jobId, retryAttempt, cancellatio
 0233    }
 234
 235    private async Task ProcessCustomerReturnInternalAsync(
 236        int ozonClientId,
 237        int daysBack,
 238        int? scheduleId,
 239        string triggerType,
 240        string? jobId,
 241        int retryAttempt,
 242        CancellationToken cancellationToken = default)
 243    {
 0244        var executionId = await _loggingService.StartExecutionAsync(scheduleId, "CustomerReturn", triggerType, cancellat
 245
 246        try
 247        {
 0248            if (retryAttempt > 0)
 249            {
 0250                await SendLogAsync(jobId, $"↻ Повторная попытка {retryAttempt}/{TransactionJobRetryPolicy.MaxRetryAttemp
 251            }
 252
 0253            var step1Id = await _loggingService.StartStepAsync(executionId, 1, "LoadReport", new { ozonClientId, daysBac
 0254            await SendLogAsync(jobId, "Шаг 1/7: Загрузка отчёта возвратов...");
 0255            var reportPeriods = BuildReportPeriods(daysBack);
 0256            await SendLogAsync(jobId,
 0257                $"Загрузка отчёта (API): {reportPeriods.DownloadFrom:dd.MM.yyyy} — {reportPeriods.DownloadTo:dd.MM.yyyy}
 0258            await SendLogAsync(jobId,
 0259                $"Отбор строк «{ReportDeliveryDateFilter.ReturnsStatusDateColumnName}»: {reportPeriods.FilterDateKeys[0]
 0260            var oneCOperationDate = reportPeriods.TargetDay;
 0261            var (_, fileName, shipmentNumbers) = await _reportLoader.LoadAndParseReturnsReportAsync(
 0262                ozonClientId,
 0263                reportPeriods.DownloadFrom,
 0264                reportPeriods.DownloadTo,
 0265                reportPeriods.FilterDateKeys,
 0266                executionId,
 0267                step1Id,
 0268                _loggingService,
 0269                cancellationToken);
 0270            await SendLogAsync(jobId, $"✓ Из отчёта {fileName}: {shipmentNumbers.Count} отправлений");
 0271            await _loggingService.CompleteStepAsync(step1Id, new { shipmentsCount = shipmentNumbers.Count, fileName }, c
 0272            await _loggingService.UpdateExecutionStatsAsync(executionId, totalOrders: shipmentNumbers.Count, cancellatio
 273
 0274            var step2Id = await _loggingService.StartStepAsync(executionId, 2, "MatchOrders", new { shipmentsCount = shi
 0275            await SendLogAsync(jobId, "Шаг 2/7: Сопоставление заказов по отправлениям...");
 0276            var matchedOrders = await MatchOrdersByShipmentAsync(executionId, step2Id, ozonClientId, shipmentNumbers, ca
 0277            await _loggingService.CompleteStepAsync(step2Id, new { matchedCount = matchedOrders.Count }, cancellationTok
 0278            await _loggingService.UpdateExecutionStatsAsync(executionId, matchedOrders: matchedOrders.Count, cancellatio
 279
 0280            var step3Id = await _loggingService.StartStepAsync(executionId, 3, "ExcludeProcessedOrders", new { beforeCou
 0281            var skippedOrders = matchedOrders
 0282                .Where(o => string.Equals(o.AppStatus?.Name, CustomerReturnStatusName, StringComparison.OrdinalIgnoreCas
 0283                .ToList();
 0284            var eligibleOrders = matchedOrders
 0285                .Where(o => !string.Equals(o.AppStatus?.Name, CustomerReturnStatusName, StringComparison.OrdinalIgnoreCa
 0286                .ToList();
 287
 0288            foreach (var skippedOrder in skippedOrders)
 289            {
 0290                var match = await _context.BackgroundOrderMatches
 0291                    .Where(m => m.ExecutionId == executionId && m.OrderId == skippedOrder.Id)
 0292                    .OrderByDescending(m => m.CreatedAt)
 0293                    .FirstOrDefaultAsync(cancellationToken);
 294
 0295                if (match != null)
 296                {
 0297                    match.IsEligibleForProcessing = false;
 0298                    match.RejectionReason = "Уже имеет статус 'Возврат покупателя' — пропущен";
 299                }
 300
 0301                await _loggingService.LogTransactionResultAsync(
 0302                    executionId,
 0303                    step3Id,
 0304                    match?.Id,
 0305                    skippedOrder.Id,
 0306                    null,
 0307                    null,
 0308                    "Skipped",
 0309                    "Уже имеет статус 'Возврат покупателя' — пропущен из фоновой обработки",
 0310                    cancellationToken);
 0311            }
 312
 0313            if (skippedOrders.Any())
 0314                await _context.SaveChangesAsync(cancellationToken);
 315
 0316            await _loggingService.CompleteStepAsync(step3Id, new { beforeCount = matchedOrders.Count, afterCount = eligi
 317
 0318            var step4Id = await _loggingService.StartStepAsync(executionId, 4, "ResolveWarehouses", new { eligibleCount 
 0319            await SendLogAsync(jobId, "Шаг 4/7: Подстановка складов (отправитель / получатель)...");
 0320            var (warehouseReadyOrders, warehouseSkippedCount, senderWarehouse, recipientByOrderId) =
 0321                await ResolveCustomerReturnWarehousesAsync(executionId, step4Id, eligibleOrders, jobId, cancellationToke
 0322            await _loggingService.CompleteStepAsync(step4Id,
 0323                new { readyCount = warehouseReadyOrders.Count, skippedCount = warehouseSkippedCount, senderWarehouse },
 0324                cancellationToken);
 0325            await SendLogAsync(jobId, $"✓ К обработке после складов: {warehouseReadyOrders.Count}, пропущено: {warehouse
 0326            await SendProgressAsync(jobId, 55, "Склады определены");
 327
 0328            var step5Id = await _loggingService.StartStepAsync(executionId, 5, "CreateTransactions", new { readyCount = 
 0329            await SendLogAsync(jobId, "Шаг 5/7: Проведение в 1С и создание транзакций...");
 0330            var (processedCount, failedCount) = await ProcessCustomerReturnWithOneCAsync(
 0331                executionId,
 0332                step5Id,
 0333                warehouseReadyOrders,
 0334                senderWarehouse,
 0335                recipientByOrderId,
 0336                oneCOperationDate,
 0337                scheduleId,
 0338                jobId,
 0339                cancellationToken);
 0340            await _loggingService.CompleteStepAsync(step5Id, new { processedCount, failedCount }, cancellationToken);
 0341            await _loggingService.UpdateExecutionStatsAsync(
 0342                executionId,
 0343                processedOrders: processedCount + failedCount,
 0344                successfulOrders: processedCount,
 0345                failedOrders: failedCount,
 0346                cancellationToken: cancellationToken);
 0347            await SendLogAsync(jobId, $"✓ Проведено: {processedCount}, ошибок: {failedCount}");
 0348            await SendProgressAsync(jobId, 90, "Проведение завершено");
 349
 0350            var step6Id = await _loggingService.StartStepAsync(executionId, 6, "Finalize", cancellationToken: cancellati
 0351            await _loggingService.CompleteStepAsync(step6Id,
 0352                new { processedCount, failedCount, statusSkipped = skippedOrders.Count, warehouseSkipped = warehouseSkip
 0353                cancellationToken);
 0354            await _loggingService.CompleteExecutionAsync(executionId, cancellationToken);
 0355            await TryNotifyTransactionCompletedAsync("Возвраты покупателей",
 0356                shipmentNumbers.Count, matchedOrders.Count, processedCount, failedCount);
 357
 0358            var totalSkipped = skippedOrders.Count + warehouseSkippedCount;
 0359            await SendCompleteAsync(jobId,
 0360                $"<strong>Всего:</strong> {shipmentNumbers.Count} | <strong>Найдено:</strong> {matchedOrders.Count} | " 
 0361                $"<strong>Проведено:</strong> {processedCount} | <strong>Пропущено:</strong> {totalSkipped} | <strong>Ош
 0362            await SendProgressAsync(jobId, 100, "Готово");
 363
 0364            await MaybeScheduleRetryAsync(
 0365                executionId, scheduleId, "CustomerReturn", ozonClientId, daysBack, jobId, retryAttempt, cancellationToke
 0366        }
 0367        catch (Exception ex)
 368        {
 0369            await _loggingService.FailExecutionAsync(executionId, ex.Message, ex.StackTrace, cancellationToken);
 0370            await SendErrorAsync(jobId, ex.Message);
 0371            await MaybeScheduleRetryAsync(
 0372                executionId, scheduleId, "CustomerReturn", ozonClientId, daysBack, jobId, retryAttempt, cancellationToke
 0373            throw;
 0374        }
 0375    }
 376
 377    private async Task ProcessShippedToCommissionerInternalAsync(
 378        int ozonClientId,
 379        int daysBack,
 380        int? scheduleId,
 381        string triggerType,
 382        string? jobId,
 383        int retryAttempt,
 384        CancellationToken cancellationToken = default)
 385    {
 0386        var executionId = await _loggingService.StartExecutionAsync(scheduleId, "ShippedToCommissioner", triggerType, ca
 387
 388        try
 389        {
 0390            if (retryAttempt > 0)
 391            {
 0392                await SendLogAsync(jobId, $"↻ Повторная попытка {retryAttempt}/{TransactionJobRetryPolicy.MaxRetryAttemp
 393            }
 394
 0395            await SendLogAsync(jobId, $"Старт обработки. Execution ID: {executionId}");
 0396            Console.WriteLine($"[AutomatedTransactionProcessing] Execution {executionId} started");
 397
 398            // Step 1: широкая выгрузка у Ozon (ProcessedAt ~ месяц), отбор строк — вчера MSK по дате доставки
 0399            var step1Id = await _loggingService.StartStepAsync(executionId, 1, "LoadReport", new { ozonClientId, daysBac
 0400            await SendLogAsync(jobId, "Шаг 1/8: Загрузка отчёта от Ozon API...");
 0401            var reportPeriods = BuildReportPeriods(daysBack);
 0402            await SendLogAsync(jobId,
 0403                $"Загрузка отчёта (API): {reportPeriods.DownloadFrom:dd.MM.yyyy} — {reportPeriods.DownloadTo:dd.MM.yyyy}
 0404            await SendLogAsync(jobId,
 0405                $"Отбор строк «{ReportDeliveryDateFilter.DeliveryDateColumnName}»: {reportPeriods.FilterDateKeys[0]} (вч
 0406            var oneCOperationDate = reportPeriods.TargetDay;
 407
 0408            var (fileBytes, fileName, parsedOrders) = await _reportLoader.LoadAndParseReportAsync(
 0409                ozonClientId,
 0410                reportPeriods.DownloadFrom,
 0411                reportPeriods.DownloadTo,
 0412                reportPeriods.FilterDateKeys,
 0413                executionId,
 0414                step1Id,
 0415                _loggingService,
 0416                cancellationToken);
 417
 0418            await _loggingService.CompleteStepAsync(step1Id, new { ordersCount = parsedOrders.Count }, cancellationToken
 0419            await _loggingService.UpdateExecutionStatsAsync(executionId, totalOrders: parsedOrders.Count, cancellationTo
 0420            await SendLogAsync(jobId, $"✓ Загружено {parsedOrders.Count} строк из файла {fileName}");
 0421            await SendProgressAsync(jobId, 14, "Отчёт загружен и отфильтрован");
 422
 423            // Step 2: Match orders in database
 0424            var step2Id = await _loggingService.StartStepAsync(executionId, 2, "MatchOrders", new { ordersCount = parsed
 0425            await SendLogAsync(jobId, "Шаг 2/8: Поиск заказов в базе данных...");
 0426            var matchedOrders = await MatchOrdersInDatabaseAsync(executionId, step2Id, parsedOrders, jobId, cancellation
 0427            await _loggingService.CompleteStepAsync(step2Id, new { matchedCount = matchedOrders.Count }, cancellationTok
 0428            await _loggingService.UpdateExecutionStatsAsync(executionId, matchedOrders: matchedOrders.Count, cancellatio
 0429            await SendLogAsync(jobId, $"✓ Найдено {matchedOrders.Count} заказов в БД");
 0430            await SendProgressAsync(jobId, 28, "Заказы найдены");
 431
 432            // Step 3: Exclude already processed orders
 0433            var step3Id = await _loggingService.StartStepAsync(executionId, 3, "ExcludeProcessedOrders", new { beforeCou
 0434            await SendLogAsync(jobId, "Шаг 3/8: Исключение уже проведённых заказов...");
 0435            var commissionerSkippedOrders = matchedOrders.Where(IsAlreadyShippedToCommissioner).ToList();
 0436            foreach (var skippedOrder in commissionerSkippedOrders)
 437            {
 0438                var match = await _context.BackgroundOrderMatches
 0439                    .Where(m => m.ExecutionId == executionId && m.OrderId == skippedOrder.Id)
 0440                    .OrderByDescending(m => m.CreatedAt)
 0441                    .FirstOrDefaultAsync(cancellationToken);
 442
 0443                if (match != null)
 444                {
 0445                    match.IsEligibleForProcessing = false;
 0446                    match.RejectionReason = "Уже имеет статус 'Отгружен комиссионеру' — пропущен";
 447                }
 448
 0449                await _loggingService.LogTransactionResultAsync(
 0450                    executionId,
 0451                    step3Id,
 0452                    match?.Id,
 0453                    skippedOrder.Id,
 0454                    skippedOrder.ShipmentWarehouse?.Name,
 0455                    null,
 0456                    "Skipped",
 0457                    "Уже имеет статус 'Отгружен комиссионеру' — пропущен из фоновой обработки",
 0458                    cancellationToken);
 0459            }
 460
 461            // Не фильтруем по IsAccepted: после «Отгружен реализатором» заказ уже IsAccepted=true,
 462            // но его всё равно нужно довести до «Отгружен комиссионеру» (только смена статуса).
 0463            var unprocessedOrders = matchedOrders
 0464                .Where(o => !IsAlreadyShippedToCommissioner(o))
 0465                .ToList();
 0466            var excludedCount = matchedOrders.Count - unprocessedOrders.Count;
 0467            await _loggingService.CompleteStepAsync(step3Id, new { beforeCount = matchedOrders.Count, afterCount = unpro
 0468            if (commissionerSkippedOrders.Any())
 469            {
 0470                await _context.SaveChangesAsync(cancellationToken);
 0471                await SendLogAsync(jobId, $"✓ Пропущено по статусу 'Отгружен комиссионеру': {commissionerSkippedOrders.C
 472            }
 0473            await SendLogAsync(jobId, $"✓ Исключено {excludedCount} уже проведённых заказов");
 0474            await SendLogAsync(jobId, $"✓ Осталось {unprocessedOrders.Count} подходящих заказов");
 0475            await SendProgressAsync(jobId, 42, "Исключение проведённых завершено");
 476
 477            // Step 4: Filter eligible orders
 0478            var step4Id = await _loggingService.StartStepAsync(executionId, 4, "FilterEligibleOrders", new { matchedCoun
 0479            await SendLogAsync(jobId, "Шаг 4/8: Фильтрация подходящих заказов...");
 0480            var eligibleOrders = await FilterEligibleOrdersAsync(unprocessedOrders, executionId, jobId, cancellationToke
 0481            await _loggingService.CompleteStepAsync(step4Id, new { eligibleCount = eligibleOrders.Count }, cancellationT
 0482            await SendLogAsync(jobId, $"✓ Отфильтровано {eligibleOrders.Count} подходящих заказов");
 0483            await SendProgressAsync(jobId, 56, "Фильтрация завершена");
 484
 485            // Steps 5–7: батчи по OrderProcessingBatchSize (склад → транзакции → 1С)
 0486            var orderBatches = eligibleOrders
 0487                .Chunk(OrderProcessingBatchSize)
 0488                .Select(batch => batch.ToList())
 0489                .ToList();
 0490            var batchCount = orderBatches.Count;
 491
 0492            var step5Id = await _loggingService.StartStepAsync(executionId, 5, "GroupByWarehouse",
 0493                new { eligibleCount = eligibleOrders.Count, batchSize = OrderProcessingBatchSize, batchCount }, cancella
 0494            var step6Id = await _loggingService.StartStepAsync(executionId, 6, "CreateTransactions",
 0495                new { batchCount }, cancellationToken);
 0496            var step7Id = await _loggingService.StartStepAsync(executionId, 7, "PostTo1C",
 0497                new { batchCount }, cancellationToken);
 498
 0499            await SendLogAsync(jobId,
 0500                $"Шаги 5–7/8: обработка {eligibleOrders.Count} заказов в {batchCount} батч(ах) по {OrderProcessingBatchS
 501
 0502            var senderWarehouseByOrderId = new Dictionary<int, string>();
 0503            var transactionResultIds = new List<long>();
 0504            var allWarehouseFailureDetails = new List<string>();
 0505            var totalWarehouseSkipped = 0;
 0506            var warehouseGroupCount = 0;
 0507            var successCount = 0;
 0508            var failCount = 0;
 509
 0510            for (var batchIndex = 0; batchIndex < batchCount; batchIndex++)
 511            {
 0512                var batchOrders = orderBatches[batchIndex];
 0513                var batchNo = batchIndex + 1;
 0514                await SendLogAsync(jobId, $"—— Батч {batchNo}/{batchCount}: {batchOrders.Count} заказов ——");
 515
 0516                var batchResolution = await ResolveCommissionerSenderWarehousesAsync(
 0517                    batchOrders, executionId, step5Id, jobId, cancellationToken);
 0518                allWarehouseFailureDetails.AddRange(batchResolution.FailureDetails);
 0519                totalWarehouseSkipped += batchResolution.SkippedCount;
 520
 0521                if (!batchResolution.ReadyOrders.Any())
 522                {
 0523                    await SendLogAsync(jobId,
 0524                        $"Батч {batchNo}: ни у одного заказа не определён склад отправителя, проведение пропущено");
 0525                    continue;
 526                }
 527
 0528                foreach (var kv in batchResolution.WarehouseByOrderId)
 529                {
 0530                    senderWarehouseByOrderId[kv.Key] = kv.Value;
 531                }
 532
 0533                var warehouseGroups = GroupByWarehouse(
 0534                    batchResolution.ReadyOrders, batchResolution.WarehouseByOrderId);
 0535                warehouseGroupCount += warehouseGroups.Count;
 536
 0537                await SendLogAsync(jobId, $"Батч {batchNo}: создание транзакций ({warehouseGroups.Count} склад(ов))…");
 0538                var batchTransactionIds = await CreateTransactionsAsync(
 0539                    executionId, step6Id, warehouseGroups, scheduleId, jobId, cancellationToken);
 0540                transactionResultIds.AddRange(batchTransactionIds);
 541
 0542                if (batchTransactionIds.Any())
 543                {
 0544                    await SendLogAsync(jobId, $"Батч {batchNo}: проведение в 1С ({batchTransactionIds.Count} результатов
 0545                    var (batchSuccess, batchFail) = await PostTransactionsTo1CAsync(
 0546                        executionId,
 0547                        step7Id,
 0548                        batchTransactionIds,
 0549                        batchResolution.WarehouseByOrderId,
 0550                        oneCOperationDate,
 0551                        jobId,
 0552                        cancellationToken);
 0553                    successCount += batchSuccess;
 0554                    failCount += batchFail;
 555                }
 556
 0557                var batchProgress = 70 + (int)Math.Round(28.0 * batchNo / batchCount);
 0558                await SendProgressAsync(jobId, batchProgress, $"Батч {batchNo}/{batchCount} обработан");
 0559            }
 560
 0561            if (!transactionResultIds.Any() && eligibleOrders.Count > 0)
 562            {
 0563                var fullError = BuildWarehouseResolutionFailureMessage(
 0564                    allWarehouseFailureDetails, eligibleOrders.Count, totalWarehouseSkipped);
 0565                await _loggingService.FailStepAsync(step5Id, TruncateErrorMessage(fullError), cancellationToken);
 0566                await _loggingService.FailExecutionAsync(executionId, TruncateErrorMessage(fullError), null, cancellatio
 0567                await SendErrorAsync(jobId, TruncateErrorMessage(fullError));
 0568                return;
 569            }
 570
 0571            await _loggingService.CompleteStepAsync(step5Id,
 0572                new { warehouseCount = warehouseGroupCount, batchCount, warehouseSkipped = totalWarehouseSkipped },
 0573                cancellationToken);
 0574            await SendLogAsync(jobId,
 0575                $"✓ Склады: {warehouseGroupCount} групп, пропущено без связки: {totalWarehouseSkipped}");
 0576            await SendProgressAsync(jobId, 70, "Группировка завершена");
 577
 0578            await _loggingService.CompleteStepAsync(step6Id, new { createdCount = transactionResultIds.Count }, cancella
 0579            await _loggingService.UpdateExecutionStatsAsync(executionId, processedOrders: transactionResultIds.Count, ca
 0580            await SendLogAsync(jobId, $"✓ Создано результатов транзакций: {transactionResultIds.Count}");
 0581            await SendProgressAsync(jobId, 84, "Транзакции созданы");
 582
 0583            await _loggingService.CompleteStepAsync(step7Id, new { successCount, failCount }, cancellationToken);
 0584            await _loggingService.UpdateExecutionStatsAsync(executionId, successfulOrders: successCount, failedOrders: f
 0585            await SendLogAsync(jobId, $"✓ Проведено в 1С: {successCount} успешно, {failCount} ошибок");
 0586            await SendProgressAsync(jobId, 98, "1С проведение завершено");
 587
 588            // Step 8: Finalize
 0589            await SendLogAsync(jobId, "Шаг 8/8: Финализация...");
 0590            await _loggingService.CompleteExecutionAsync(executionId, cancellationToken);
 0591            await TryNotifyTransactionCompletedAsync("Отгружен комиссионеру",
 0592                parsedOrders.Count, matchedOrders.Count, successCount, failCount);
 0593            await SendLogAsync(jobId, "✓ Обработка завершена");
 0594            await SendProgressAsync(jobId, 100, "Готово");
 0595            await SendCompleteAsync(jobId, $"<strong>Всего:</strong> {parsedOrders.Count} | <strong>Найдено:</strong> {m
 0596            Console.WriteLine($"[AutomatedTransactionProcessing] Execution {executionId} completed successfully");
 597
 0598            await MaybeScheduleRetryAsync(
 0599                executionId, scheduleId, "ShippedToCommissioner", ozonClientId, daysBack, jobId, retryAttempt, cancellat
 0600        }
 0601        catch (Exception ex)
 602        {
 0603            Console.WriteLine($"[AutomatedTransactionProcessing] Execution {executionId} failed: {ex.Message}");
 0604            await _loggingService.FailExecutionAsync(executionId, ex.Message, ex.StackTrace, cancellationToken);
 0605            await SendErrorAsync(jobId, ex.Message);
 0606            await MaybeScheduleRetryAsync(
 0607                executionId, scheduleId, "ShippedToCommissioner", ozonClientId, daysBack, jobId, retryAttempt, cancellat
 0608            throw;
 0609        }
 0610    }
 611
 612    private static async Task TryNotifyTransactionCompletedAsync(
 613        string jobType, int total, int matched, int success, int failed)
 614    {
 615        try
 616        {
 0617            var color = failed > 0 ? "#D97706" : "#16A34A";
 0618            var icon = failed > 0 ? "fa-triangle-exclamation" : "fa-check";
 0619            await NotificationService.NotifyAsync(
 0620                "Транзакции", color,
 0621                $"{jobType}: всего {total}, найдено {matched}, проведено {success}, ошибок {failed}",
 0622                icon);
 0623        }
 0624        catch { /* не роняем джоб */ }
 0625    }
 626
 627    private async Task MaybeScheduleRetryAsync(
 628        long executionId,
 629        int? scheduleId,
 630        string jobType,
 631        int ozonClientId,
 632        int daysBack,
 633        string? jobId,
 634        int retryAttempt,
 635        CancellationToken cancellationToken)
 636    {
 0637        if (retryAttempt >= TransactionJobRetryPolicy.MaxRetryAttempts)
 638        {
 0639            var limitMessage =
 0640                $"⚠ Лимит повторных запусков исчерпан ({TransactionJobRetryPolicy.MaxRetryAttempts})";
 0641            Console.WriteLine($"[TransactionJobRetry] {limitMessage} execution={executionId}");
 0642            await SendLogAsync(jobId, limitMessage);
 0643            return;
 644        }
 645
 0646        var execution = await _loggingService.GetExecutionAsync(executionId, cancellationToken);
 0647        var hasFailedTransactionResults = await _context.BackgroundTransactionResults
 0648            .AnyAsync(
 0649                r => r.ExecutionId == executionId && r.Status == "Failed",
 0650                cancellationToken);
 651
 0652        if (!TransactionJobRetryPolicy.ShouldRetry(execution, hasFailedTransactionResults))
 0653            return;
 654
 0655        var nextAttempt = retryAttempt + 1;
 0656        var delay = TransactionJobRetryPolicy.GetDelayForAttempt(nextAttempt);
 0657        var delayText = delay.TotalMinutes >= 60
 0658            ? $"{delay.TotalHours:0.#} ч"
 0659            : $"{delay.TotalMinutes:0} мин";
 0660        var logMessage =
 0661            $"↻ Запланирован повтор {nextAttempt}/{TransactionJobRetryPolicy.MaxRetryAttempts} через {delayText} " +
 0662            $"(статус: {execution?.Status}, ошибок заказов: {execution?.FailedOrders ?? 0})";
 663
 0664        Console.WriteLine($"[TransactionJobRetry] {logMessage} execution={executionId}");
 0665        await SendLogAsync(jobId, logMessage);
 666
 0667        if (scheduleId.HasValue)
 668        {
 0669            var schedule = await _scheduleService.GetScheduleByIdAsync(scheduleId.Value, cancellationToken);
 0670            if (schedule is { IsActive: true })
 0671                _retryScheduler.ScheduleScheduledRetry(scheduleId.Value, jobType, nextAttempt);
 672        }
 673        else
 674        {
 0675            _retryScheduler.ScheduleManualRetry(ozonClientId, daysBack, jobType, jobId, nextAttempt);
 676        }
 0677    }
 678
 679    private async Task SendLogAsync(string? jobId, string message)
 680    {
 0681        if (string.IsNullOrWhiteSpace(jobId)) return;
 0682        await _hubContext.Clients.Group(jobId).SendAsync("Log", message);
 0683    }
 684
 685    private async Task SendProgressAsync(string? jobId, int percent, string message)
 686    {
 0687        if (string.IsNullOrWhiteSpace(jobId)) return;
 0688        await _hubContext.Clients.Group(jobId).SendAsync("Progress", percent, message);
 0689    }
 690
 691    private async Task SendCompleteAsync(string? jobId, string summary)
 692    {
 0693        if (string.IsNullOrWhiteSpace(jobId)) return;
 0694        await _hubContext.Clients.Group(jobId).SendAsync("Complete", summary);
 0695    }
 696
 697    private async Task SendErrorAsync(string? jobId, string errorMessage)
 698    {
 0699        if (string.IsNullOrWhiteSpace(jobId)) return;
 0700        await _hubContext.Clients.Group(jobId).SendAsync("Error", errorMessage);
 0701    }
 702
 703    private static ReportDeliveryDateFilter.TransactionJobReportPeriods BuildReportPeriods(int daysBack) =>
 0704        ReportDeliveryDateFilter.BuildTransactionJobPeriods(
 0705            DateTime.UtcNow,
 0706            RussianTimeZone,
 0707            daysBack < 1 ? ReportDeliveryDateFilter.TransactionJobDaysBack : daysBack);
 708
 709    private async Task<List<Order>> MatchOrdersInDatabaseAsync(long executionId, long stepId, List<(string OrderNumber, 
 710    {
 0711        var batches = parsedOrders.Chunk(OrderProcessingBatchSize).Select(c => c.ToList()).ToList();
 0712        await SendLogAsync(jobId,
 0713            $"Поиск {parsedOrders.Count} заказов в базе данных ({batches.Count} батч(ей) по {OrderProcessingBatchSize})…
 714
 0715        var ordersById = new Dictionary<int, Order>();
 0716        var orderMatches = new List<BackgroundOrderMatch>();
 717
 0718        for (var batchIndex = 0; batchIndex < batches.Count; batchIndex++)
 719        {
 0720            var batch = batches[batchIndex];
 0721            if (batches.Count > 1)
 722            {
 0723                await SendLogAsync(jobId, $"Поиск в БД: батч {batchIndex + 1}/{batches.Count} ({batch.Count} строк)…");
 724            }
 725
 0726            var orderIds = await _orderServices.GetOrderIdsByNumbersAndArticles(batch);
 0727            var batchOrders = await _orderServices.GetOrdersNoTracking(orderIds.ToArray());
 728
 0729            foreach (var parsed in batch)
 730            {
 731                // Только заказы, реально сопоставленные строке отчёта: поиск по номеру+артикулу
 732                // может вернуть и соседние отправления того же заказа, которых в отчёте нет.
 0733                var matchedForRow = FindOrdersForReportRow(batchOrders, parsed);
 734
 0735                if (matchedForRow.Count == 0)
 736                {
 0737                    orderMatches.Add(new BackgroundOrderMatch
 0738                    {
 0739                        ExecutionId = executionId,
 0740                        StepId = stepId,
 0741                        OrderNumber = parsed.OrderNumber,
 0742                        Article = parsed.Article,
 0743                        OrderId = null,
 0744                        OrderStatus = null,
 0745                        IsMatchedInDb = false,
 0746                        IsEligibleForProcessing = false,
 0747                        RejectionReason = "Заказ не найден в БД",
 0748                        CreatedAt = DateTime.UtcNow
 0749                    });
 0750                    continue;
 751                }
 752
 0753                foreach (var order in matchedForRow)
 754                {
 0755                    ordersById[order.Id] = order;
 0756                    orderMatches.Add(new BackgroundOrderMatch
 0757                    {
 0758                        ExecutionId = executionId,
 0759                        StepId = stepId,
 0760                        OrderNumber = parsed.OrderNumber,
 0761                        Article = parsed.Article,
 0762                        OrderId = order.Id,
 0763                        OrderStatus = order.AppStatus?.Name ?? order.Status,
 0764                        IsMatchedInDb = true,
 0765                        IsEligibleForProcessing = false,
 0766                        RejectionReason = null,
 0767                        CreatedAt = DateTime.UtcNow
 0768                    });
 769                }
 770            }
 0771        }
 772
 0773        await SendLogAsync(jobId, $"База данных вернула {ordersById.Count} совпадений");
 0774        await _loggingService.BulkLogOrderMatchesAsync(orderMatches, cancellationToken);
 0775        return ordersById.Values.ToList();
 0776    }
 777
 778    /// <summary>
 779    /// Заказы, соответствующие строке отчёта: совпадение по номеру отправления (без внутреннего
 780    /// суффикса позиции «/1») и артикулу. Разделённый заказ даёт разные отправления («-1», «-3»),
 781    /// и подтягивать соседнее отправление по строке отчёта нельзя.
 782    /// </summary>
 783    internal static List<Order> FindOrdersForReportRow(
 784        IEnumerable<Order?> candidates,
 785        (string OrderNumber, string Article) reportRow)
 786    {
 6787        var normalizedNumber = ShipmentNumberMatching.Normalize(reportRow.OrderNumber);
 6788        if (string.IsNullOrEmpty(normalizedNumber))
 1789            return [];
 790
 5791        return candidates
 5792            .Where(o =>
 9793                o != null &&
 9794                string.Equals(
 9795                    ShipmentNumberMatching.Normalize(o.ShipmentNumber),
 9796                    normalizedNumber,
 9797                    StringComparison.OrdinalIgnoreCase) &&
 9798                string.Equals(o.ProductKey, reportRow.Article, StringComparison.OrdinalIgnoreCase))
 5799            .Select(o => o!)
 5800            .ToList();
 801    }
 802
 803    private async Task<List<Order>> FilterEligibleOrdersAsync(
 804        List<Order> orders, long executionId, string? jobId, CancellationToken cancellationToken)
 805    {
 0806        var eligibleOrders = orders.Where(IsEligibleForAutomatedProcessing).ToList();
 807
 0808        var orderIds = orders.Select(o => o.Id).ToList();
 0809        var allMatches = await _context.BackgroundOrderMatches
 0810            .Where(m => m.ExecutionId == executionId && m.OrderId != null && orderIds.Contains(m.OrderId.Value))
 0811            .OrderByDescending(m => m.CreatedAt)
 0812            .ToListAsync(cancellationToken);
 0813        var latestMatchByOrderId = allMatches
 0814            .GroupBy(m => m.OrderId!.Value)
 0815            .ToDictionary(g => g.Key, g => g.First());
 816
 0817        foreach (var order in orders)
 818        {
 0819            var isEligible = eligibleOrders.Contains(order);
 0820            if (!latestMatchByOrderId.TryGetValue(order.Id, out var match)) continue;
 0821            match.IsEligibleForProcessing = isEligible;
 0822            match.RejectionReason = isEligible ? null : $"Статус '{order.AppStatus?.Name}' не подходит для обработки";
 823        }
 824
 0825        await _context.SaveChangesAsync(cancellationToken);
 0826        return eligibleOrders;
 0827    }
 828
 829    private async Task<CommissionerWarehouseBatchResult> ResolveCommissionerSenderWarehousesAsync(
 830            List<Order> eligibleOrders,
 831            long executionId,
 832            long stepId,
 833            string? jobId,
 834            CancellationToken cancellationToken)
 835    {
 0836        var resolution = await _warehouseSelectionResolver.ResolveCommissionerSenderWarehousesAsync(eligibleOrders);
 0837        var readyOrders = new List<Order>();
 0838        var warehouseByOrderId = new Dictionary<int, string>();
 0839        var failureDetails = new List<string>();
 0840        var skippedCount = 0;
 841
 842        // Batch-load matches once instead of one query per skipped order
 0843        var orderIds = eligibleOrders.Select(o => o.Id).ToList();
 0844        var allMatches = await _context.BackgroundOrderMatches
 0845            .Where(m => m.ExecutionId == executionId && m.OrderId != null && orderIds.Contains(m.OrderId.Value))
 0846            .OrderByDescending(m => m.CreatedAt)
 0847            .ToListAsync(cancellationToken);
 0848        var matchByOrderId = allMatches
 0849            .GroupBy(m => m.OrderId!.Value)
 0850            .ToDictionary(g => g.Key, g => g.First());
 851
 0852        foreach (var order in eligibleOrders)
 853        {
 0854            if (resolution.WarehouseByOrderId.TryGetValue(order.Id, out var warehouse) &&
 0855                !string.IsNullOrWhiteSpace(warehouse))
 856            {
 0857                warehouseByOrderId[order.Id] = warehouse.Trim();
 0858                readyOrders.Add(order);
 0859                continue;
 860            }
 861
 0862            skippedCount++;
 0863            var reason = resolution.BlockingErrors
 0864                .FirstOrDefault(e => (order.ShipmentNumber != null &&
 0865                                      e.Contains(order.ShipmentNumber, StringComparison.OrdinalIgnoreCase)) ||
 0866                                     e.Contains(order.Id.ToString(), StringComparison.OrdinalIgnoreCase))
 0867                ?? "Не найдена связка WarehouseMapping для склада отправителя";
 0868            var detail = $"{order.ShipmentNumber ?? $"ID {order.Id}"}: {reason}";
 0869            failureDetails.Add(detail);
 0870            await SendLogAsync(jobId, $"⊘ {detail}");
 0871            matchByOrderId.TryGetValue(order.Id, out var match);
 0872            await LogCommissionerProcessingSkippedAsync(
 0873                executionId, stepId, order, null, reason, match, cancellationToken);
 0874        }
 875
 0876        if (skippedCount > 0)
 877        {
 0878            await SendLogAsync(jobId, $"Пропущено заказов без склада отправителя в батче: {skippedCount}");
 0879            await _context.SaveChangesAsync(cancellationToken); // batch save after all skips
 880        }
 881
 0882        if (readyOrders.Any())
 883        {
 0884            foreach (var group in warehouseByOrderId
 0885                         .GroupBy(kv => kv.Value, StringComparer.OrdinalIgnoreCase)
 0886                         .OrderBy(g => g.Key))
 887            {
 0888                await SendLogAsync(jobId,
 0889                    $"Склад отправителя (связка): {group.Key} — заказов {group.Count()}");
 890            }
 891        }
 892
 0893        return new CommissionerWarehouseBatchResult
 0894        {
 0895            ReadyOrders = readyOrders,
 0896            WarehouseByOrderId = warehouseByOrderId,
 0897            SkippedCount = skippedCount,
 0898            FailureDetails = failureDetails
 0899        };
 0900    }
 901
 902    internal static string BuildWarehouseResolutionFailureMessage(
 903        IReadOnlyList<string> details,
 904        int eligibleCount,
 905        int skippedCount)
 906    {
 4907        var header =
 4908            $"Не удалось определить склад отправителя по связкам складов (WarehouseMapping). " +
 4909            $"Подходящих заказов: {eligibleCount}, без связки: {skippedCount}.";
 910
 4911        if (details.Count == 0)
 912        {
 1913            return header + " Детали по заказам не собраны — проверьте логи этапа «Группировка по складам».";
 914        }
 915
 3916        var distinct = details.Distinct(StringComparer.OrdinalIgnoreCase).Take(150).ToList();
 3917        var body = string.Join(Environment.NewLine, distinct);
 3918        if (details.Count > distinct.Count)
 919        {
 2920            body += Environment.NewLine + $"... и ещё {details.Count - distinct.Count} записей (см. лог задания).";
 921        }
 922
 3923        return header + Environment.NewLine + body;
 924    }
 925
 926    internal static string TruncateErrorMessage(string message)
 927    {
 5928        if (string.IsNullOrEmpty(message) || message.Length <= MaxExecutionErrorMessageLength)
 929        {
 4930            return message;
 931        }
 932
 1933        return message[..MaxExecutionErrorMessageLength] + "…";
 934    }
 935
 936    internal static Dictionary<string, List<Order>> GroupByWarehouse(
 937        List<Order> orders,
 938        IReadOnlyDictionary<int, string> senderWarehouseByOrderId)
 939    {
 7940        return orders
 11941            .GroupBy(o => senderWarehouseByOrderId.TryGetValue(o.Id, out var wh) && !string.IsNullOrWhiteSpace(wh)
 11942                ? wh
 11943                : "Склад не определен",
 7944                StringComparer.OrdinalIgnoreCase)
 21945            .ToDictionary(g => g.Key, g => g.ToList());
 946    }
 947
 948    internal static string BuildAutomatedCommissionerComment(int? scheduleId) =>
 3949        $"Проведено автоматически системой {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC" +
 3950        (scheduleId.HasValue ? $" (расписание ID: {scheduleId.Value})" : " (ручной запуск)");
 951
 952    private async Task<List<long>> CreateTransactionsAsync(
 953        long executionId, long stepId, Dictionary<string, List<Order>> warehouseGroups, int? scheduleId, string? jobId, 
 954    {
 0955        var resultIds = new List<long>();
 0956        var successfulTransactions = new List<(int transactionId, int orderId, string warehouseName)>();
 0957        var failedTransactions = new List<(int orderId, string warehouseName, string errorMessage)>();
 0958        var commissionerStatus = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = CommissionerStatusNam
 0959        if (commissionerStatus == null)
 960        {
 0961            throw new InvalidOperationException($"Не найден статус '{CommissionerStatusName}'");
 962        }
 963
 0964        foreach (var (warehouseName, orders) in warehouseGroups)
 965        {
 0966            if (warehouseName == "Склад не определен")
 967            {
 0968                await SendLogAsync(jobId, $"Пропущен склад '{warehouseName}' с {orders.Count} заказами");
 0969                continue;
 970            }
 971
 0972            var statusOnlyOrders = orders.Where(IsStatusOnlyCommissionerChange).ToList();
 0973            var transactionOrders = orders.Where(o => !IsStatusOnlyCommissionerChange(o)).ToList();
 974
 0975            if (statusOnlyOrders.Any())
 976            {
 0977                resultIds.AddRange(await ApplyCommissionerStatusOnlyAsync(
 0978                    executionId,
 0979                    stepId,
 0980                    statusOnlyOrders,
 0981                    warehouseName,
 0982                    scheduleId,
 0983                    commissionerStatus,
 0984                    jobId,
 0985                    cancellationToken));
 986            }
 987
 0988            if (!transactionOrders.Any())
 989            {
 990                continue;
 991            }
 992
 0993            await SendLogAsync(jobId,
 0994                $"Создание транзакции для склада '{warehouseName}' ({transactionOrders.Count} заказов, с документом и 1С
 995
 996            try
 997            {
 0998                var comment = BuildAutomatedCommissionerComment(scheduleId);
 0999                var performedAt = DateTime.UtcNow;
 01000                var fromStatusByOrderId = transactionOrders.ToDictionary(o => o.Id, o => o.AppStatusId ?? 0);
 01001                ApplySystemAttribution(transactionOrders);
 01002                ClearOrderNavigationForPersist(transactionOrders);
 1003
 01004                foreach (var order in transactionOrders)
 1005                {
 01006                    if (!string.Equals(order.Status, "Отменен", StringComparison.OrdinalIgnoreCase))
 1007                    {
 01008                        order.AppStatusId = commissionerStatus.Id;
 01009                        order.AppStatus = commissionerStatus;
 1010                    }
 1011                }
 1012
 01013                var (_, _, transactionId) = await _transactionManager.CreateShippedToCommissionerTransaction(
 01014                    transactionOrders,
 01015                    SystemUserName,
 01016                    performedAt,
 01017                    comment);
 1018
 01019                foreach (var order in transactionOrders)
 1020                {
 01021                    order.AppStatus = null;
 1022                }
 1023
 01024                await _orderServices.UpdateOrders(transactionOrders);
 01025                await TryRecordStatusTransitionHistoryAsync(
 01026                    TransactionType.ShippedToCommissioner,
 01027                    transactionOrders,
 01028                    fromStatusByOrderId,
 01029                    comment,
 01030                    performedAt,
 01031                    cancellationToken);
 1032
 01033                foreach (var order in transactionOrders)
 1034                {
 01035                    successfulTransactions.Add((transactionId, order.Id, warehouseName));
 1036                }
 1037
 01038                await SendLogAsync(jobId,
 01039                    $"✓ Склад '{warehouseName}': создана 1 транзакция для {transactionOrders.Count} заказов");
 01040            }
 01041            catch (Exception ex)
 1042            {
 01043                var errorMessage = ex.Message;
 01044                var innerException = ex.InnerException;
 01045                while (innerException != null)
 1046                {
 01047                    errorMessage += $" → {innerException.Message}";
 01048                    innerException = innerException.InnerException;
 1049                }
 1050
 01051                await SendLogAsync(jobId, $"✗ Ошибка создания транзакции для склада '{warehouseName}': {errorMessage}");
 01052                Console.WriteLine($"[AutomatedTransactionProcessing] Transaction creation error for warehouse {warehouse
 1053
 01054                foreach (var order in transactionOrders)
 1055                {
 01056                    failedTransactions.Add((order.Id, warehouseName, errorMessage));
 1057                }
 01058            }
 01059        }
 1060
 01061        foreach (var (transactionId, orderId, warehouseName) in successfulTransactions)
 1062        {
 1063            try
 1064            {
 01065                var resultId = await _loggingService.LogTransactionResultAsync(
 01066                    executionId, stepId, null, orderId, warehouseName,
 01067                    transactionId, "Created", null, cancellationToken);
 01068                resultIds.Add(resultId);
 01069            }
 01070            catch (Exception ex)
 1071            {
 01072                await SendLogAsync(jobId, $"✗ Ошибка логирования результата для заказа {orderId}: {ex.Message}");
 1073            }
 1074        }
 1075
 01076        foreach (var (orderId, warehouseName, errorMessage) in failedTransactions)
 1077        {
 1078            try
 1079            {
 01080                var resultId = await _loggingService.LogTransactionResultAsync(
 01081                    executionId, stepId, null, orderId, warehouseName,
 01082                    null, "Failed", null, cancellationToken);
 1083
 01084                await _loggingService.UpdateTransactionResultAsync(
 01085                    resultId, errorMessage: errorMessage, status: "Failed", cancellationToken: cancellationToken);
 1086
 01087                resultIds.Add(resultId);
 01088            }
 01089            catch (Exception ex)
 1090            {
 01091                await SendLogAsync(jobId, $"✗ Ошибка логирования ошибки для заказа {orderId}: {ex.Message}");
 1092            }
 01093        }
 1094
 01095        return resultIds;
 01096    }
 1097
 1098    private async Task<(int successCount, int failCount)> PostTransactionsTo1CAsync(
 1099        long executionId,
 1100        long stepId,
 1101        List<long> transactionResultIds,
 1102        IReadOnlyDictionary<int, string> senderWarehouseByOrderId,
 1103        DateTime oneCOperationDate,
 1104        string? jobId,
 1105        CancellationToken cancellationToken)
 1106    {
 01107        int successCount = 0;
 01108        int failCount = 0;
 1109
 01110        var results = await _context.BackgroundTransactionResults
 01111            .Where(r => transactionResultIds.Contains(r.Id))
 01112            .OrderBy(r => r.CreatedAt)
 01113            .ToListAsync(cancellationToken);
 1114
 01115        var systemOnlyResults = results
 01116            .Where(r => string.Equals(r.Status, "SystemOnly", StringComparison.OrdinalIgnoreCase))
 01117            .ToList();
 01118        if (systemOnlyResults.Any())
 1119        {
 01120            successCount += systemOnlyResults.Count;
 01121            foreach (var result in systemOnlyResults)
 1122            {
 01123                await SendLogAsync(jobId, $"✓ Заказ {result.OrderId} проведён только в системе, 1С пропущена");
 1124            }
 1125        }
 1126
 01127        var resultsToPost = results
 01128            .Where(r => r.Status != "Failed"
 01129                && r.TransactionId.HasValue
 01130                && !string.Equals(r.Status, "PostedTo1C", StringComparison.OrdinalIgnoreCase)
 01131                && !string.Equals(r.Status, "SystemOnly", StringComparison.OrdinalIgnoreCase)
 01132                && string.IsNullOrEmpty(r.OneCDocumentId))   // dedup: не повторять уже задокументированные
 01133            .GroupBy(r => r.TransactionId!.Value)
 01134            .ToList();
 1135
 01136        foreach (var transactionGroup in resultsToPost)
 1137        {
 01138            var transactionId = transactionGroup.Key;
 01139            var orderIds = transactionGroup.Select(r => r.OrderId).Distinct().ToArray();
 01140            var orders = await _orderServices.GetOrdersNoTracking(orderIds);
 01141            if (!orders.Any())
 1142            {
 01143                await _loggingService.BulkUpdateTransactionResultsAsync(
 01144                    transactionGroup.Select(r => (
 01145                        resultId: r.Id,
 01146                        transactionId: (int?)transactionId,
 01147                        oneCDocumentId: (string?)null,
 01148                        oneCDocumentNumber: (string?)null,
 01149                        status: (string?)"Failed",
 01150                        errorMessage: (string?)"Не удалось загрузить заказы для проведения в 1С",
 01151                        comment: (string?)null,
 01152                        isPosted: (bool?)null)),
 01153                    cancellationToken);
 01154                failCount++;
 01155                continue;
 1156            }
 1157
 01158            var orderWarehouseMap = new Dictionary<int, string>();
 01159            foreach (var order in orders)
 1160            {
 01161                if (senderWarehouseByOrderId.TryGetValue(order.Id, out var warehouseName) &&
 01162                    !string.IsNullOrWhiteSpace(warehouseName))
 1163                {
 01164                    orderWarehouseMap[order.Id] = warehouseName;
 1165                }
 1166            }
 1167
 1168            try
 1169            {
 01170                await SendLogAsync(jobId, $"Проведение в 1С транзакции ID {transactionId} для {orders.Count} заказов..."
 1171
 01172                var maxRetries = 3;
 01173                var retryDelay = TimeSpan.FromSeconds(5);
 01174                List<OneCResponse> transferResult = [];
 01175                Exception? lastException = null;
 1176
 01177                for (var attempt = 1; attempt <= maxRetries; attempt++)
 1178                {
 1179                    try
 1180                    {
 01181                        transferResult = await _oneCTransferManager.TransferStockByWarehousesWithReversedWarehouses(
 01182                            orders,
 01183                            TransactionType.ShippedToCommissioner,
 01184                            orderWarehouseMap,
 01185                            oneCOperationDate,
 01186                            SystemUserName);
 01187                        lastException = null;
 01188                        break;
 1189                    }
 1190                    catch (Exception ex)
 1191                    {
 01192                        lastException = ex;
 01193                        var detail = FormatExceptionDetail(ex);
 01194                        Console.WriteLine($"[AutomatedTransactionProcessing] 1C posting failed (attempt {attempt}/{maxRe
 01195                        if (attempt < maxRetries)
 1196                        {
 01197                            await Task.Delay(retryDelay, cancellationToken);
 1198                        }
 1199                    }
 1200                }
 1201
 01202                if (lastException != null)
 1203                {
 01204                    var errorMessage = $"1C posting failed after {maxRetries} attempts: {FormatExceptionDetail(lastExcep
 01205                    await _loggingService.BulkUpdateTransactionResultsAsync(
 01206                        transactionGroup.Select(r => (
 01207                            resultId: r.Id,
 01208                            transactionId: (int?)transactionId,
 01209                            oneCDocumentId: (string?)null,
 01210                            oneCDocumentNumber: (string?)null,
 01211                            status: (string?)"Failed",
 01212                            errorMessage: (string?)errorMessage,
 01213                            comment: (string?)null,
 01214                            isPosted: (bool?)null)),
 01215                        cancellationToken);
 01216                    await SendLogAsync(jobId, $"✗ Ошибка проведения в 1С для транзакции {transactionId}: {FormatExceptio
 01217                    failCount += transactionGroup.Count();
 01218                    continue;
 1219                }
 1220
 01221                var successfulTransfers = transferResult.Where(tr => tr.Success).ToList();
 01222                var failedTransfers = transferResult.Where(tr => !tr.Success).ToList();
 01223                if (!successfulTransfers.Any())
 1224                {
 01225                    var errorMessage = failedTransfers.Any()
 01226                        ? string.Join("; ", failedTransfers.Select(f => f.Message).Distinct().Take(3))
 01227                        : "1C не вернул успешный документ";
 01228                    errorMessage = OneCResponseErrors.AppendOneCDetails(errorMessage, failedTransfers) ?? errorMessage;
 1229
 01230                    await _loggingService.BulkUpdateTransactionResultsAsync(
 01231                        transactionGroup.Select(r => (
 01232                            resultId: r.Id,
 01233                            transactionId: (int?)transactionId,
 01234                            oneCDocumentId: (string?)null,
 01235                            oneCDocumentNumber: (string?)null,
 01236                            status: (string?)"Failed",
 01237                            errorMessage: (string?)errorMessage,
 01238                            comment: (string?)null,
 01239                            isPosted: (bool?)null)),
 01240                        cancellationToken);
 01241                    await SendLogAsync(jobId, $"✗ Ошибка проведения в 1С для транзакции {transactionId}: {errorMessage}"
 01242                    failCount += transactionGroup.Count();
 01243                    continue;
 1244                }
 1245
 01246                var oneCDocumentIds = successfulTransfers
 01247                    .Select(tr => tr.TransactionId)  // GUID Ref_Key
 01248                    .Where(id => !string.IsNullOrWhiteSpace(id))
 01249                    .Distinct()
 01250                    .ToArray();
 01251                var oneCDocumentNumbers = successfulTransfers
 01252                    .Select(tr => !string.IsNullOrWhiteSpace(tr.DocumentNumber) ? tr.DocumentNumber : tr.TransactionId)
 01253                    .Where(id => !string.IsNullOrWhiteSpace(id))
 01254                    .Distinct()
 01255                    .ToArray();
 1256
 01257                if (oneCDocumentNumbers.Any())
 1258                {
 01259                    await _transactionDataServices.UpdateTransactionComment(
 01260                        transactionId,
 01261                        string.Join(", ", oneCDocumentNumbers));
 1262                }
 1263
 01264                var documentId = successfulTransfers.First().TransactionId;       // GUID
 01265                var documentNumber = !string.IsNullOrWhiteSpace(successfulTransfers.First().DocumentNumber)
 01266                    ? successfulTransfers.First().DocumentNumber
 01267                    : successfulTransfers.First().TransactionId;
 01268                if (successfulTransfers.Count > 1)
 1269                {
 01270                    await SendLogAsync(jobId, $"⚠️ Транзакция {transactionId} вернула {successfulTransfers.Count} докуме
 1271                }
 1272
 01273                var allPosted = successfulTransfers.All(tr => tr.IsPosted);
 01274                var missingNom = string.Join("; ",
 01275                    successfulTransfers
 01276                        .Where(tr => !string.IsNullOrWhiteSpace(tr.MissingNomenclature))
 01277                        .Select(tr => tr.MissingNomenclature)
 01278                        .Distinct());
 01279                var postingErrors = string.Join("; ",
 01280                    successfulTransfers
 01281                        .Where(tr => !tr.IsPosted && !string.IsNullOrWhiteSpace(tr.PostingError))
 01282                        .Select(tr => tr.PostingError)
 01283                        .Distinct());
 01284                var draftDetail = !string.IsNullOrWhiteSpace(postingErrors)
 01285                    ? postingErrors
 01286                    : !string.IsNullOrWhiteSpace(missingNom)
 01287                        ? $"не найдена номенклатура: {missingNom}"
 01288                        : allPosted ? null : "документ не проведён в 1С (черновик)";
 01289                if (!allPosted)
 01290                    draftDetail = OneCResponseErrors.AppendOneCDetails(draftDetail, successfulTransfers.Where(tr => !tr.
 1291
 01292                await _loggingService.BulkUpdateTransactionResultsAsync(
 01293                    transactionGroup.Select(r => (
 01294                        resultId: r.Id,
 01295                        transactionId: (int?)transactionId,
 01296                        oneCDocumentId: documentId,          // GUID
 01297                        oneCDocumentNumber: documentNumber,  // НФY1-001620
 01298                        status: (string?)(allPosted ? "PostedTo1C" : "DraftIn1C"),
 01299                        errorMessage: (string?)(allPosted ? null : draftDetail),
 01300                        comment: oneCDocumentNumbers.Any() ? string.Join(", ", oneCDocumentNumbers) : documentNumber,
 01301                        isPosted: (bool?)allPosted)),
 01302                    cancellationToken);
 1303
 01304                var historyWindowStart = DateTime.UtcNow.AddMinutes(-30);
 01305                var historySenderWarehouses = orders
 01306                    .Select(o => orderWarehouseMap.TryGetValue(o.Id, out var w) ? w : null)
 01307                    .Where(w => !string.IsNullOrWhiteSpace(w))
 01308                    .Distinct(StringComparer.OrdinalIgnoreCase)
 01309                    .ToList();
 01310                var historyRecipient = orders
 01311                    .Select(o => o.OzonClient?.WarehouseName?.Trim())
 01312                    .FirstOrDefault(w => !string.IsNullOrWhiteSpace(w));
 01313                await TryRecordOneCHistoryMovementAsync(
 01314                    orders,
 01315                    transferResult,
 01316                    transactionId,
 01317                    historyWindowStart,
 01318                    historySenderWarehouses.Count > 0 ? string.Join(", ", historySenderWarehouses) : null,
 01319                    historyRecipient);
 1320
 01321                successCount += transactionGroup.Count();
 01322                if (!allPosted)
 1323                {
 01324                    await SendLogAsync(jobId,
 01325                        $"⚠ 1С черновик для транзакции {transactionId}: {documentNumber} ({draftDetail})");
 1326                }
 1327                else
 1328                {
 01329                    await SendLogAsync(jobId, $"✓ 1С документ создан для транзакции {transactionId}: {documentNumber}");
 1330                }
 01331            }
 01332            catch (Exception ex)
 1333            {
 01334                var detail = FormatExceptionDetail(ex);
 01335                Console.WriteLine($"[AutomatedTransactionProcessing] 1C posting error for transaction {transactionId}: {
 01336                await _loggingService.BulkUpdateTransactionResultsAsync(
 01337                    transactionGroup.Select(r => (
 01338                        resultId: r.Id,
 01339                        transactionId: (int?)transactionId,
 01340                        oneCDocumentId: (string?)null,
 01341                        oneCDocumentNumber: (string?)null,
 01342                        status: (string?)"Failed",
 01343                        errorMessage: (string?)detail,
 01344                        comment: (string?)null,
 01345                        isPosted: (bool?)null)),
 01346                    cancellationToken);
 01347                await SendLogAsync(jobId, $"✗ Ошибка проведения в 1С для транзакции {transactionId}: {detail}");
 01348                failCount += transactionGroup.Count();
 01349            }
 01350        }
 1351
 01352        return (successCount, failCount);
 01353    }
 1354
 1355    private async Task<List<Order>> MatchOrdersByShipmentAsync(
 1356        long executionId,
 1357        long stepId,
 1358        int ozonClientId,
 1359        List<string> shipmentNumbers,
 1360        CancellationToken cancellationToken)
 1361    {
 01362        var cleanShipments = shipmentNumbers
 01363            .Where(x => !string.IsNullOrWhiteSpace(x))
 01364            .Select(x => x.Trim())
 01365            .Distinct(StringComparer.OrdinalIgnoreCase)
 01366            .ToList();
 1367
 01368        if (cleanShipments.Count == 0)
 01369            return [];
 1370
 01371        var (orderIds, matchedShipments) = await _orderServices.GetOrderIdsAndMatchedShipmentsByShipmentListAsync(cleanS
 01372        var orders = await _orderServices.GetOrdersNoTracking(orderIds.ToArray());
 01373        var ordersByShipment = orders
 01374            .GroupBy(o => _orderServices.NormalizeOrderNumberForMatching(o.ShipmentNumber),
 01375                StringComparer.OrdinalIgnoreCase)
 01376            .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
 1377
 01378        var matches = new List<BackgroundOrderMatch>(cleanShipments.Count);
 01379        foreach (var shipment in cleanShipments)
 1380        {
 01381            var normalized = _orderServices.NormalizeOrderNumberForMatching(shipment);
 1382            // Одному отправлению может соответствовать несколько строк заказа («…-1/1», «…-1/2»),
 1383            // и каждая должна попасть в лог сопоставления.
 01384            var shipmentOrders = ordersByShipment.GetValueOrDefault(normalized) ?? [];
 01385            var isMatched = shipmentOrders.Count > 0 || matchedShipments.Contains(shipment.Trim());
 1386
 01387            if (shipmentOrders.Count == 0)
 1388            {
 01389                matches.Add(new BackgroundOrderMatch
 01390                {
 01391                    ExecutionId = executionId,
 01392                    StepId = stepId,
 01393                    OrderNumber = shipment,
 01394                    Article = shipment,
 01395                    OrderId = null,
 01396                    OrderStatus = null,
 01397                    IsMatchedInDb = isMatched,
 01398                    IsEligibleForProcessing = false,
 01399                    RejectionReason = isMatched ? null : "Заказ не найден в БД",
 01400                    CreatedAt = DateTime.UtcNow
 01401                });
 01402                continue;
 1403            }
 1404
 01405            foreach (var order in shipmentOrders)
 1406            {
 01407                matches.Add(new BackgroundOrderMatch
 01408                {
 01409                    ExecutionId = executionId,
 01410                    StepId = stepId,
 01411                    OrderNumber = shipment,
 01412                    Article = shipment,
 01413                    OrderId = order.Id,
 01414                    OrderStatus = order.AppStatus?.Name ?? order.Status,
 01415                    IsMatchedInDb = true,
 01416                    IsEligibleForProcessing = true,
 01417                    RejectionReason = null,
 01418                    CreatedAt = DateTime.UtcNow
 01419                });
 1420            }
 1421        }
 1422
 01423        await _loggingService.BulkLogOrderMatchesAsync(matches, cancellationToken);
 01424        return orders;
 01425    }
 1426
 1427    private async Task<(List<Order> readyOrders, int skippedCount, string senderWarehouse, Dictionary<int, string> recip
 1428        ResolveCustomerReturnWarehousesAsync(
 1429            long executionId,
 1430            long stepId,
 1431            List<Order> eligibleOrders,
 1432            string? jobId,
 1433            CancellationToken cancellationToken)
 1434    {
 01435        var readyOrders = new List<Order>();
 01436        var recipientByOrderId = new Dictionary<int, string>();
 01437        var skippedCount = 0;
 1438
 01439        if (!eligibleOrders.Any())
 01440            return (readyOrders, skippedCount, string.Empty, recipientByOrderId);
 1441
 1442        // Batch-load matches once for all eligible orders
 01443        var orderIds = eligibleOrders.Select(o => o.Id).ToList();
 01444        var allMatches = await _context.BackgroundOrderMatches
 01445            .Where(m => m.ExecutionId == executionId && m.OrderId != null && orderIds.Contains(m.OrderId.Value))
 01446            .OrderByDescending(m => m.CreatedAt)
 01447            .ToListAsync(cancellationToken);
 01448        var matchByOrderId = allMatches
 01449            .GroupBy(m => m.OrderId!.Value)
 01450            .ToDictionary(g => g.Key, g => g.First());
 1451
 01452        var senderResolution = await _warehouseSelectionResolver.ResolveSenderWarehouseAsync(eligibleOrders);
 01453        if (senderResolution.HasErrors || string.IsNullOrWhiteSpace(senderResolution.SenderWarehouseName))
 1454        {
 01455            var reason = senderResolution.HasErrors
 01456                ? string.Join("; ", senderResolution.Errors.Distinct())
 01457                : "Не удалось определить склад отправителя (склад покупателя Ozon).";
 01458            await SendLogAsync(jobId, $"✗ Склад отправителя не определён: {reason}");
 01459            foreach (var order in eligibleOrders)
 1460            {
 01461                skippedCount++;
 01462                matchByOrderId.TryGetValue(order.Id, out var match);
 01463                await LogCustomerReturnSkippedAsync(executionId, stepId, order, null, reason, match, cancellationToken);
 1464            }
 1465
 01466            await _context.SaveChangesAsync(cancellationToken);
 01467            return (readyOrders, skippedCount, string.Empty, recipientByOrderId);
 1468        }
 1469
 01470        var senderWarehouse = senderResolution.SenderWarehouseName.Trim();
 01471        await SendLogAsync(jobId, $"Склад отправителя (покупатель): {senderWarehouse}");
 1472
 01473        var recipientResolution = await _warehouseSelectionResolver.ResolveCustomerReturnRecipientByOrderAsync(eligibleO
 01474        foreach (var order in eligibleOrders)
 1475        {
 01476            if (recipientResolution.RecipientByOrderId.TryGetValue(order.Id, out var recipient))
 1477            {
 01478                recipient = recipient.Trim();
 01479                if (string.Equals(senderWarehouse, recipient, StringComparison.OrdinalIgnoreCase))
 1480                {
 01481                    skippedCount++;
 01482                    var sameWhReason = $"Склад отправителя и получателя совпадают ('{senderWarehouse}')";
 01483                    await SendLogAsync(jobId, $"⊘ {order.ShipmentNumber}: {sameWhReason}");
 01484                    matchByOrderId.TryGetValue(order.Id, out var match);
 01485                    await LogCustomerReturnSkippedAsync(executionId, stepId, order, recipient, sameWhReason, match, canc
 01486                    continue;
 1487                }
 1488
 01489                recipientByOrderId[order.Id] = recipient;
 01490                readyOrders.Add(order);
 01491                await SendLogAsync(jobId, $"✓ {order.ShipmentNumber}: {senderWarehouse} → {recipient}");
 01492                continue;
 1493            }
 1494
 01495            skippedCount++;
 01496            var orderReason = recipientResolution.BlockingErrors
 01497                .FirstOrDefault(e => e.Contains(order.ShipmentNumber ?? order.Id.ToString(), StringComparison.OrdinalIgn
 01498                ?? "Не найден маппинг склада получателя (WarehouseMappings)";
 01499            await SendLogAsync(jobId, $"⊘ {order.ShipmentNumber}: {orderReason}");
 01500            matchByOrderId.TryGetValue(order.Id, out var skipMatch);
 01501            await LogCustomerReturnSkippedAsync(executionId, stepId, order, null, orderReason, skipMatch, cancellationTo
 01502        }
 1503
 01504        if (skippedCount > 0)
 01505            await _context.SaveChangesAsync(cancellationToken); // batch save after all skips
 1506
 01507        return (readyOrders, skippedCount, senderWarehouse, recipientByOrderId);
 01508    }
 1509
 1510    private async Task<List<long>> ApplyCommissionerStatusOnlyAsync(
 1511        long executionId,
 1512        long stepId,
 1513        List<Order> orders,
 1514        string warehouseName,
 1515        int? scheduleId,
 1516        AppStatus commissionerStatus,
 1517        string? jobId,
 1518        CancellationToken cancellationToken)
 1519    {
 01520        var resultIds = new List<long>();
 01521        if (!orders.Any())
 1522        {
 01523            return resultIds;
 1524        }
 1525
 01526        var comment = BuildAutomatedCommissionerComment(scheduleId) +
 01527                      $" ({SellerStatusName}: только смена статуса, без документа и 1С)";
 01528        var performedAt = DateTime.UtcNow;
 01529        var fromStatusByOrderId = orders.ToDictionary(o => o.Id, o => o.AppStatusId ?? 0);
 01530        ApplySystemAttribution(orders);
 01531        ClearOrderNavigationForPersist(orders);
 1532
 01533        foreach (var order in orders)
 1534        {
 01535            if (!string.Equals(order.Status, "Отменен", StringComparison.OrdinalIgnoreCase))
 1536            {
 01537                order.AppStatusId = commissionerStatus.Id;
 01538                order.AppStatus = commissionerStatus;
 1539            }
 1540        }
 1541
 1542        try
 1543        {
 01544            foreach (var order in orders)
 1545            {
 01546                order.AppStatus = null;
 1547            }
 1548
 01549            await _orderServices.UpdateOrders(orders);
 01550            await TryRecordStatusTransitionHistoryAsync(
 01551                TransactionType.ShippedToCommissioner,
 01552                orders,
 01553                fromStatusByOrderId,
 01554                comment,
 01555                performedAt,
 01556                cancellationToken);
 1557
 01558            var numbers = string.Join(", ", orders
 01559                .Select(o => o.ShipmentNumber)
 01560                .Where(n => !string.IsNullOrWhiteSpace(n))
 01561                .Distinct(StringComparer.OrdinalIgnoreCase));
 01562            await SendLogAsync(jobId,
 01563                $"✓ Склад '{warehouseName}': смена статуса без документа для {orders.Count} заказ(ов){(string.IsNullOrWh
 1564
 01565            foreach (var order in orders)
 1566            {
 01567                var resultId = await _loggingService.LogTransactionResultAsync(
 01568                    executionId,
 01569                    stepId,
 01570                    null,
 01571                    order.Id,
 01572                    warehouseName,
 01573                    null,
 01574                    "SystemOnly",
 01575                    $"Статус '{SellerStatusName}': смена на '{CommissionerStatusName}' без документа транзакции и без 1С
 01576                    cancellationToken);
 01577                resultIds.Add(resultId);
 1578            }
 01579        }
 01580        catch (Exception ex)
 1581        {
 01582            var errorMessage = FormatExceptionDetail(ex);
 01583            await SendLogAsync(jobId, $"✗ Ошибка смены статуса для склада '{warehouseName}': {errorMessage}");
 01584            foreach (var order in orders)
 1585            {
 01586                var resultId = await _loggingService.LogTransactionResultAsync(
 01587                    executionId, stepId, null, order.Id, warehouseName,
 01588                    null, "Failed", null, cancellationToken);
 01589                await _loggingService.UpdateTransactionResultAsync(
 01590                    resultId, errorMessage: errorMessage, status: "Failed", cancellationToken: cancellationToken);
 01591                resultIds.Add(resultId);
 1592            }
 01593        }
 1594
 01595        return resultIds;
 01596    }
 1597
 1598    private async Task LogCommissionerProcessingSkippedAsync(
 1599        long executionId,
 1600        long stepId,
 1601        Order order,
 1602        string? warehouseName,
 1603        string reason,
 1604        BackgroundOrderMatch? preloadedMatch,
 1605        CancellationToken cancellationToken)
 1606    {
 01607        if (preloadedMatch != null)
 1608        {
 01609            preloadedMatch.IsEligibleForProcessing = false;
 01610            preloadedMatch.RejectionReason = reason.Length > 500 ? reason[..500] : reason;
 1611        }
 1612
 01613        await _loggingService.LogTransactionResultAsync(
 01614            executionId,
 01615            stepId,
 01616            preloadedMatch?.Id,
 01617            order.Id,
 01618            warehouseName,
 01619            null,
 01620            "Skipped",
 01621            reason,
 01622            cancellationToken);
 01623    }
 1624
 1625    private async Task LogCustomerReturnSkippedAsync(
 1626        long executionId,
 1627        long stepId,
 1628        Order order,
 1629        string? recipientWarehouse,
 1630        string reason,
 1631        BackgroundOrderMatch? preloadedMatch,
 1632        CancellationToken cancellationToken)
 1633    {
 01634        if (preloadedMatch != null)
 1635        {
 01636            preloadedMatch.IsEligibleForProcessing = false;
 01637            preloadedMatch.RejectionReason = reason.Length > 500 ? reason[..500] : reason;
 1638        }
 1639
 01640        await _loggingService.LogTransactionResultAsync(
 01641            executionId,
 01642            stepId,
 01643            preloadedMatch?.Id,
 01644            order.Id,
 01645            recipientWarehouse,
 01646            null,
 01647            "Skipped",
 01648            reason,
 01649            cancellationToken);
 01650    }
 1651
 1652    private async Task<(int processedCount, int failedCount)> ProcessCustomerReturnWithOneCAsync(
 1653        long executionId,
 1654        long stepId,
 1655        List<Order> readyOrders,
 1656        string senderWarehouse,
 1657        Dictionary<int, string> recipientByOrderId,
 1658        DateTime oneCOperationDate,
 1659        int? scheduleId,
 1660        string? jobId,
 1661        CancellationToken cancellationToken)
 1662    {
 01663        if (!readyOrders.Any() || string.IsNullOrWhiteSpace(senderWarehouse))
 01664            return (0, 0);
 1665
 01666        var customerReturnStatus = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = CustomerReturnStatu
 01667        if (customerReturnStatus == null)
 01668            throw new Exception($"Не найден статус '{CustomerReturnStatusName}'");
 1669
 01670        var processedCount = 0;
 01671        var failedCount = 0;
 01672        var groups = readyOrders
 01673            .GroupBy(o => recipientByOrderId[o.Id], StringComparer.OrdinalIgnoreCase)
 01674            .ToList();
 1675
 01676        await SendLogAsync(jobId, $"Групп по складам получателя: {groups.Count}");
 1677
 01678        foreach (var group in groups)
 1679        {
 01680            var recipient = group.Key.Trim();
 01681            var groupOrders = group.ToList();
 01682            var shipmentList = string.Join(", ", groupOrders.Select(o => o.ShipmentNumber).Where(x => !string.IsNullOrWh
 01683            await SendLogAsync(jobId, $"▶ Группа «{recipient}»: {groupOrders.Count} заказ(ов) ({shipmentList})");
 1684
 01685            var ordersFor1C = await _orderServices.GetOrdersNoTracking(groupOrders.Select(o => o.Id).ToArray());
 01686            if (!ordersFor1C.Any())
 1687            {
 01688                foreach (var order in groupOrders)
 1689                {
 01690                    failedCount++;
 01691                    await LogCustomerReturnFailedAsync(executionId, stepId, order, recipient,
 01692                        "Не удалось загрузить заказы для проведения в 1С", null, cancellationToken);
 1693                }
 1694
 01695                continue;
 1696            }
 1697
 1698            List<OneCResponse> transferResult;
 1699            try
 1700            {
 01701                transferResult = await PostCustomerReturnTo1CWithRetryAsync(
 01702                    ordersFor1C,
 01703                    senderWarehouse,
 01704                    recipient,
 01705                    oneCOperationDate,
 01706                    jobId,
 01707                    cancellationToken);
 01708            }
 01709            catch (Exception ex)
 1710            {
 01711                foreach (var order in groupOrders)
 1712                {
 01713                    failedCount++;
 01714                    await LogCustomerReturnFailedAsync(executionId, stepId, order, recipient, FormatExceptionDetail(ex),
 1715                }
 1716
 01717                continue;
 1718            }
 1719
 01720            var successfulTransfers = transferResult.Where(tr => tr.Success).ToList();
 01721            var failedTransfers = transferResult.Where(tr => !tr.Success).ToList();
 1722
 01723            if (!successfulTransfers.Any())
 1724            {
 01725                var errorMessage = failedTransfers.Any()
 01726                    ? string.Join("; ", failedTransfers.Select(f => f.Message).Distinct().Take(3))
 01727                    : "1С не вернул успешный документ";
 01728                await SendLogAsync(jobId, $"✗ 1С: группа «{recipient}» — {errorMessage}");
 01729                foreach (var order in groupOrders)
 1730                {
 01731                    failedCount++;
 01732                    await LogCustomerReturnFailedAsync(executionId, stepId, order, recipient, errorMessage, null, cancel
 1733                }
 1734
 01735                continue;
 1736            }
 1737
 01738            var oneCDocumentIds = successfulTransfers
 01739                .Select(tr => tr.TransactionId)  // GUID Ref_Key
 01740                .Where(id => !string.IsNullOrWhiteSpace(id))
 01741                .Distinct()
 01742                .ToList();
 1743            // Читаемые номера документов ("НФY1-001620")
 01744            var oneCDocumentLabels = successfulTransfers
 01745                .Select(tr => !string.IsNullOrWhiteSpace(tr.DocumentNumber) ? tr.DocumentNumber : tr.TransactionId)
 01746                .Where(id => !string.IsNullOrWhiteSpace(id))
 01747                .Distinct()
 01748                .ToList();
 01749            var documentLabel = oneCDocumentLabels.Any() ? string.Join(", ", oneCDocumentLabels) : "—";
 01750            var groupIsPostedForLog = successfulTransfers.All(tr => tr.IsPosted);
 01751            if (groupIsPostedForLog)
 1752            {
 01753                await SendLogAsync(jobId, $"✓ 1С: группа «{recipient}» — документ(ы): {documentLabel}");
 1754            }
 1755            else
 1756            {
 01757                var postingErrors = string.Join("; ",
 01758                    successfulTransfers
 01759                        .Where(tr => !tr.IsPosted && !string.IsNullOrWhiteSpace(tr.PostingError))
 01760                        .Select(tr => tr.PostingError)
 01761                        .Distinct());
 01762                var draftDetail = string.IsNullOrWhiteSpace(postingErrors) ? "документ не проведён" : postingErrors;
 01763                await SendLogAsync(jobId, $"⚠ 1С: группа «{recipient}» — документ(ы): {documentLabel} ({draftDetail})");
 1764            }
 1765
 01766            var trackedOrders = await _orderServices.GetOrdersByIdsTracked(
 01767                groupOrders.Select(o => o.Id).Distinct().ToList(), includeArchived: false);
 01768            if (!trackedOrders.Any())
 1769            {
 01770                foreach (var order in groupOrders)
 1771                {
 01772                    failedCount++;
 01773                    await LogCustomerReturnFailedAsync(executionId, stepId, order, recipient,
 01774                        "1С проведён, но не удалось обновить заказы в системе", null, cancellationToken);
 1775                }
 1776
 01777                continue;
 1778            }
 1779
 1780            try
 1781            {
 01782                var historyWindowStart = DateTime.UtcNow;
 01783                var fromStatusByOrderId = trackedOrders.ToDictionary(o => o.Id, o => o.AppStatusId ?? 0);
 01784                var performedAt = DateTime.UtcNow;
 01785                var lastStatusChangeDate = TimeZoneInfo.ConvertTime(performedAt, RussianTimeZone);
 01786                ApplySystemAttribution(trackedOrders);
 1787
 01788                foreach (var order in trackedOrders)
 1789                {
 01790                    order.AppStatusId = customerReturnStatus.Id;
 01791                    order.LastStatusChangeDate = lastStatusChangeDate;
 1792                }
 1793
 01794                var comment = $"Проведено автоматически системой {performedAt:yyyy-MM-dd HH:mm:ss} UTC" +
 01795                              (scheduleId.HasValue ? $" (расписание ID: {scheduleId.Value})" : " (ручной запуск)") +
 01796                              (oneCDocumentLabels.Any()
 01797                                  ? $". 1С: {senderWarehouse} → {recipient}, dok. {string.Join(", ", oneCDocumentLabels)
 01798                                  : $". 1С: {senderWarehouse} → {recipient}");
 1799
 01800                var (_, _, transactionId) = await _transactionManager.CreateCustomerReturnTransaction(
 01801                    trackedOrders,
 01802                    SystemUserName,
 01803                    performedAt,
 01804                    comment);
 1805
 01806                if (oneCDocumentIds.Any())
 1807                {
 01808                    await _transactionDataServices.UpdateTransactionComment(
 01809                        transactionId,
 01810                        comment);
 1811                }
 1812
 01813                var orderIds = trackedOrders.Select(o => o.Id).Distinct().ToList();
 01814                var statusRowsUpdated = await PersistCustomerReturnStatusAsync(
 01815                    orderIds,
 01816                    customerReturnStatus.Id,
 01817                    lastStatusChangeDate,
 01818                    cancellationToken);
 1819
 01820                if (statusRowsUpdated == 0)
 1821                {
 01822                    throw new InvalidOperationException(
 01823                        "Транзакция создана, но статус «Возврат покупателя» не записан в заказы");
 1824                }
 1825
 01826                await _orderServices.UpdateOrders(trackedOrders);
 1827
 01828                foreach (var order in trackedOrders)
 1829                {
 01830                    order.AppStatus = customerReturnStatus;
 1831                }
 1832
 01833                await TryRecordStatusTransitionHistoryAsync(
 01834                    TransactionType.CustomerReturn,
 01835                    trackedOrders,
 01836                    fromStatusByOrderId,
 01837                    comment,
 01838                    performedAt,
 01839                    cancellationToken);
 1840
 01841                await TryRecordOneCHistoryMovementAsync(
 01842                    trackedOrders,
 01843                    transferResult,
 01844                    transactionId,
 01845                    historyWindowStart,
 01846                    senderWarehouse,
 01847                    recipient);
 1848
 1849                // Берём первый документ для сохранения в результат каждого заказа группы
 01850                var firstDocGuid = oneCDocumentIds.FirstOrDefault();      // GUID Ref_Key
 01851                var firstDocNumber = oneCDocumentLabels.FirstOrDefault(); // "НФY1-001620"
 01852                var groupIsPosted = successfulTransfers.All(tr => tr.IsPosted);
 01853                var crPostingErrors = string.Join("; ",
 01854                    successfulTransfers
 01855                        .Where(tr => !tr.IsPosted && !string.IsNullOrWhiteSpace(tr.PostingError))
 01856                        .Select(tr => tr.PostingError)
 01857                        .Distinct());
 01858                var crMissingNom = string.Join("; ",
 01859                    successfulTransfers
 01860                        .Where(tr => !string.IsNullOrWhiteSpace(tr.MissingNomenclature))
 01861                        .Select(tr => tr.MissingNomenclature)
 01862                        .Distinct());
 01863                var crDraftDetail = !string.IsNullOrWhiteSpace(crPostingErrors)
 01864                    ? crPostingErrors
 01865                    : !string.IsNullOrWhiteSpace(crMissingNom)
 01866                        ? $"не найдена номенклатура: {crMissingNom}"
 01867                        : groupIsPosted ? null : "документ не проведён в 1С (черновик)";
 01868                if (!groupIsPosted)
 01869                    crDraftDetail = OneCResponseErrors.AppendOneCDetails(crDraftDetail, successfulTransfers.Where(tr => 
 1870
 01871                foreach (var order in trackedOrders)
 1872                {
 01873                    processedCount++;
 01874                    await LogCustomerReturnPostedAsync(
 01875                        executionId, stepId, order, recipient, transactionId,
 01876                        firstDocGuid, firstDocNumber, groupIsPosted, crDraftDetail, cancellationToken);
 1877                }
 01878            }
 01879            catch (Exception ex)
 1880            {
 01881                await SendLogAsync(jobId, $"✗ Ошибка записи транзакции после 1С (группа «{recipient}»): {ex.Message}");
 01882                foreach (var order in groupOrders)
 1883                {
 01884                    failedCount++;
 01885                    await LogCustomerReturnFailedAsync(executionId, stepId, order, recipient,
 01886                        $"1С OK, ошибка системы: {ex.Message}", documentLabel, cancellationToken);
 1887                }
 01888            }
 01889        }
 1890
 01891        return (processedCount, failedCount);
 01892    }
 1893
 1894    private async Task<List<OneCResponse>> PostCustomerReturnTo1CWithRetryAsync(
 1895        List<Order> orders,
 1896        string senderWarehouse,
 1897        string recipientWarehouse,
 1898        DateTime oneCOperationDate,
 1899        string? jobId,
 1900        CancellationToken cancellationToken)
 1901    {
 1902        const int maxRetries = 3;
 01903        var retryDelay = TimeSpan.FromSeconds(5);
 01904        Exception? lastException = null;
 1905
 01906        for (var attempt = 1; attempt <= maxRetries; attempt++)
 1907        {
 1908            try
 1909            {
 01910                await SendLogAsync(jobId,
 01911                    $"  1С: перемещение {senderWarehouse} → {recipientWarehouse}, {orders.Count} заказ(ов), попытка {att
 01912                var result = await _oneCTransferManager.TransferStockWithCustomWarehouses(
 01913                    orders,
 01914                    senderWarehouse,
 01915                    recipientWarehouse,
 01916                    TransactionType.CustomerReturn,
 01917                    oneCOperationDate,
 01918                    SystemUserName);
 01919                return result;
 1920            }
 1921            catch (Exception ex)
 1922            {
 01923                lastException = ex;
 01924                var detail = FormatExceptionDetail(ex);
 01925                Console.WriteLine($"[1C] CustomerReturn попытка {attempt}/{maxRetries}: {detail}");
 01926                await SendLogAsync(jobId, $"  ✗ 1С попытка {attempt}/{maxRetries}: {detail}");
 01927                if (attempt < maxRetries)
 1928                {
 01929                    await Task.Delay(retryDelay, cancellationToken);
 1930                }
 1931            }
 1932        }
 1933
 01934        throw lastException ?? new Exception("1C posting failed");
 01935    }
 1936
 1937    private const int TransactionResultCommentMaxLength = 1000;
 1938
 1939    private async Task LogCustomerReturnPostedAsync(
 1940        long executionId,
 1941        long stepId,
 1942        Order order,
 1943        string recipientWarehouse,
 1944        int transactionId,
 1945        string? oneCDocumentId,    // GUID Ref_Key
 1946        string? oneCDocumentNumber, // Читаемый номер "НФY1-001620"
 1947        bool isPosted,
 1948        string? draftReason,
 1949        CancellationToken cancellationToken)
 1950    {
 01951        var match = await _context.BackgroundOrderMatches
 01952            .Where(m => m.ExecutionId == executionId && m.OrderId == order.Id)
 01953            .OrderByDescending(m => m.CreatedAt)
 01954            .FirstOrDefaultAsync(cancellationToken);
 1955
 01956        var docLabel = oneCDocumentNumber ?? oneCDocumentId ?? "—";
 01957        var status = isPosted ? "PostedTo1C" : "DraftIn1C";
 01958        var comment = $"1С: {docLabel}" + (!isPosted && !string.IsNullOrWhiteSpace(draftReason) ? $" ({draftReason})" : 
 1959        // Comment — varchar(1000); полный текст причины с ответом 1С лежит в ErrorMessage (text) ниже.
 01960        if (comment.Length > TransactionResultCommentMaxLength)
 01961            comment = comment[..(TransactionResultCommentMaxLength - 1)] + "…";
 01962        var resultId = await _loggingService.LogTransactionResultAsync(
 01963            executionId,
 01964            stepId,
 01965            match?.Id,
 01966            order.Id,
 01967            recipientWarehouse,
 01968            transactionId,
 01969            status,
 01970            comment,
 01971            cancellationToken);
 1972
 01973        await _loggingService.UpdateTransactionResultAsync(
 01974            resultId,
 01975            oneCDocumentId: oneCDocumentId,
 01976            oneCDocumentNumber: oneCDocumentNumber,
 01977            isPosted: isPosted,
 01978            errorMessage: !isPosted ? draftReason : null,
 01979            cancellationToken: cancellationToken);
 01980    }
 1981
 1982    private async Task LogCustomerReturnFailedAsync(
 1983        long executionId,
 1984        long stepId,
 1985        Order order,
 1986        string? recipientWarehouse,
 1987        string errorMessage,
 1988        string? oneCDocumentLabel,
 1989        CancellationToken cancellationToken)
 1990    {
 01991        var match = await _context.BackgroundOrderMatches
 01992            .Where(m => m.ExecutionId == executionId && m.OrderId == order.Id)
 01993            .OrderByDescending(m => m.CreatedAt)
 01994            .FirstOrDefaultAsync(cancellationToken);
 1995
 01996        var resultId = await _loggingService.LogTransactionResultAsync(
 01997            executionId,
 01998            stepId,
 01999            match?.Id,
 02000            order.Id,
 02001            recipientWarehouse,
 02002            null,
 02003            "Failed",
 02004            oneCDocumentLabel,
 02005            cancellationToken);
 2006
 02007        await _loggingService.UpdateTransactionResultAsync(
 02008            resultId,
 02009            status: "Failed",
 02010            errorMessage: errorMessage.Length > 2000 ? errorMessage[..2000] : errorMessage,
 02011            cancellationToken: cancellationToken);
 02012    }
 2013
 2014    internal static void ApplySystemAttribution(IEnumerable<Order> orders)
 2015    {
 102016        foreach (var order in orders)
 2017        {
 32018            order.UpdatedBy = SystemUserName;
 2019        }
 22020    }
 2021
 2022    internal static void ClearOrderNavigationForPersist(IEnumerable<Order> orders)
 2023    {
 182024        foreach (var order in orders)
 2025        {
 2026            // Save FK IDs before nulling navigation properties — EF Core would otherwise
 2027            // propagate the null back to the FK columns on SaveChanges.
 42028            var ozonClientId = order.OzonClientId;
 42029            var supplierId = order.SupplierId;
 42030            var shipmentWarehouseId = order.ShipmentWarehouseId;
 42031            var manufacturerId = order.ManufacturerId;
 42032            var appStatusId = order.AppStatusId;
 42033            var clientStatusId = order.ClientStatusId;
 42034            var currencyId = order.СurrencyId;
 42035            var deliveryId = order.DeliveryId;
 42036            var etProducerId = order.EtProducerId;
 42037            var productInfoId = order.ProductInfoId;
 42038            var excelFileDataId = order.ExcelFileDataId;
 2039
 42040            order.OzonClient = null;
 42041            order.Supplier = null;
 42042            order.Manufacturer = null;
 42043            order.AppStatus = null;
 42044            order.ClientStatus = null;
 42045            order.ShipmentWarehouse = null;
 42046            order.Delivery = null;
 42047            order.ProductInfo = null;
 42048            order.EtProducer = null;
 42049            order.OrderPrice = null;
 42050            order.ExcelFileData = null;
 42051            order.Сurrency = null;
 42052            order.OrderExtensions = [];
 2053
 2054            // Restore FK IDs so EF Core does not emit NULL for these columns.
 42055            order.OzonClientId = ozonClientId;
 42056            order.SupplierId = supplierId;
 42057            order.ShipmentWarehouseId = shipmentWarehouseId;
 42058            order.ManufacturerId = manufacturerId;
 42059            order.AppStatusId = appStatusId;
 42060            order.ClientStatusId = clientStatusId;
 42061            order.СurrencyId = currencyId;
 42062            order.DeliveryId = deliveryId;
 42063            order.EtProducerId = etProducerId;
 42064            order.ProductInfoId = productInfoId;
 42065            order.ExcelFileDataId = excelFileDataId;
 2066        }
 52067    }
 2068
 2069    private async Task<int> PersistCustomerReturnStatusAsync(
 2070        IReadOnlyCollection<int> orderIds,
 2071        int customerReturnStatusId,
 2072        DateTime lastStatusChangeDate,
 2073        CancellationToken cancellationToken)
 2074    {
 02075        if (orderIds.Count == 0)
 02076            return 0;
 2077
 02078        return await _context.Orders
 02079            .Where(o => orderIds.Contains(o.Id))
 02080            .ExecuteUpdateAsync(
 02081                s => s
 02082                    .SetProperty(o => o.AppStatusId, customerReturnStatusId)
 02083                    .SetProperty(o => o.LastStatusChangeDate, lastStatusChangeDate)
 02084                    .SetProperty(o => o.UpdatedBy, SystemUserName),
 02085                cancellationToken);
 02086    }
 2087
 2088    /// <summary>
 2089    /// Запись проведения 1С в OrderHistoryOneCPostings для отображения на /Studio2/OrderHistory/GetOrderHistory.
 2090    /// </summary>
 2091    private Task TryRecordOneCHistoryMovementAsync(
 2092        IReadOnlyList<Order> orders,
 2093        IReadOnlyList<OneCResponse> transferResult,
 2094        int transactionId,
 2095        DateTime historyWindowStart,
 2096        string? warehouseSender = null,
 2097        string? warehouseRecipient = null)
 2098    {
 02099        return OrderHistoryOneCPostingRecorder.TryRecordMovementAsync(
 02100            _serviceProvider,
 02101            orders,
 02102            transferResult,
 02103            SystemUserName,
 02104            transactionId > 0 ? transactionId : null,
 02105            historyWindowStart,
 02106            warehouseSender,
 02107            warehouseRecipient);
 2108    }
 2109
 2110    private async Task TryRecordStatusTransitionHistoryAsync(
 2111        TransactionType transactionType,
 2112        IReadOnlyList<Order> ordersWithNewStatus,
 2113        IReadOnlyDictionary<int, int> orderIdToFromStatusId,
 2114        string? comment,
 2115        DateTime performedAt,
 2116        CancellationToken cancellationToken)
 2117    {
 02118        var transitions = await _statusTransitionDataServices.GetByTransactionType(transactionType);
 02119        if (transitions.Count == 0)
 2120        {
 02121            return;
 2122        }
 2123
 02124        await _statusTransitionHistoryService.RecordStatusTransitionHistoryAsync(
 02125            transactionType,
 02126            ordersWithNewStatus,
 02127            orderIdToFromStatusId,
 02128            transitions,
 02129            comment,
 02130            performedByUserId: null,
 02131            performedAt,
 02132            metadata: "{\"performedBy\":\"Система\"}");
 02133    }
 2134
 2135    internal static string FormatExceptionDetail(Exception ex)
 2136    {
 32137        var detail = ex.ToString();
 32138        return detail.Length <= 3000 ? detail : detail[..3000] + "…";
 2139    }
 2140}

Methods/Properties

.cctor()
get_ReadyOrders()
get_WarehouseByOrderId()
get_SkippedCount()
get_FailureDetails()
IsAlreadyShippedToCommissioner(OzonDomains.Models.Order)
IsStatusOnlyCommissionerChange(OzonDomains.Models.Order)
IsEligibleForAutomatedProcessing(OzonDomains.Models.Order)
.ctor(Services.BackgroundJobsServices.IBackgroundJobLoggingService,Services.BackgroundJobsServices.IBackgroundJobScheduleService,Services.BackgroundJobsServices.ITransactionJobRetryScheduler,Services.BackgroundJobsServices.IOzonReportLoaderService,Services.DataServices.OrdersDataServices,Services.DataServices.TransactionDataServices,Services.DataServices.AppStatusDataServices,Services.TransactionUtilsServices.TransactionManager,Services.ApiServices._1CApi.OneCTransferManager,Services.DataServices.IStatusTransitionHistoryService,Services.DataServices.StatusTransitionDataServices,Services.BackgroundJobsServices.WarehouseSelectionResolver,Microsoft.AspNetCore.SignalR.IHubContext`1<Services.SignalRServices.JobProgressHub>,OzonRepositories.Context.ApplicationDbContext,System.IServiceProvider)
ProcessShippedToCommissionerAsync()
ProcessShippedToCommissionerManualAsync()
ProcessShippedToCommissionerScheduledRetryAsync()
ProcessShippedToCommissionerManualRetryAsync()
ProcessCustomerReturnAsync()
ProcessCustomerReturnManualAsync()
ProcessCustomerReturnScheduledRetryAsync()
ProcessCustomerReturnManualRetryAsync()
ProcessCustomerReturnInternalAsync()
ProcessShippedToCommissionerInternalAsync()
TryNotifyTransactionCompletedAsync()
MaybeScheduleRetryAsync()
SendLogAsync()
SendProgressAsync()
SendCompleteAsync()
SendErrorAsync()
BuildReportPeriods(System.Int32)
MatchOrdersInDatabaseAsync()
FindOrdersForReportRow(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>,System.ValueTuple`2<System.String,System.String>)
FilterEligibleOrdersAsync()
ResolveCommissionerSenderWarehousesAsync()
BuildWarehouseResolutionFailureMessage(System.Collections.Generic.IReadOnlyList`1<System.String>,System.Int32,System.Int32)
TruncateErrorMessage(System.String)
GroupByWarehouse(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.IReadOnlyDictionary`2<System.Int32,System.String>)
BuildAutomatedCommissionerComment(System.Nullable`1<System.Int32>)
CreateTransactionsAsync()
PostTransactionsTo1CAsync()
MatchOrdersByShipmentAsync()
ResolveCustomerReturnWarehousesAsync()
ApplyCommissionerStatusOnlyAsync()
LogCommissionerProcessingSkippedAsync()
LogCustomerReturnSkippedAsync()
ProcessCustomerReturnWithOneCAsync()
PostCustomerReturnTo1CWithRetryAsync()
LogCustomerReturnPostedAsync()
LogCustomerReturnFailedAsync()
ApplySystemAttribution(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>)
ClearOrderNavigationForPersist(System.Collections.Generic.IEnumerable`1<OzonDomains.Models.Order>)
PersistCustomerReturnStatusAsync()
TryRecordOneCHistoryMovementAsync(System.Collections.Generic.IReadOnlyList`1<OzonDomains.Models.Order>,System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.Int32,System.DateTime,System.String,System.String)
TryRecordStatusTransitionHistoryAsync()
FormatExceptionDetail(System.Exception)