< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Helpers.LostOrdersPreparationResult
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Helpers/LostByCommissionerTransitHelper.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 12
Coverable lines: 12
Total lines: 526
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
get_OrdersToProcess()100%210%
get_SkippedOrdersHtml()100%210%
get_OneCSuccessHtml()100%210%
get_CommentAppendix()100%210%
get_OrdersForHistory()100%210%
get_TransferResultForHistory()100%210%
get_HistorySender()100%210%
get_HistoryRecipient()100%210%
get_HistoryWindowStart()100%210%
get_AllOrdersSkipped()100%210%
get_Fatal1CErrorHtml()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Helpers/LostByCommissionerTransitHelper.cs

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using OzonDomains;
 3using OzonDomains.Models;
 4using OzonOrdersWeb.Areas.Studio2.Controllers.Base;
 5using OzonRepositories.Context;
 6using Services.ApiServices._1CApi;
 7using Services.ApiServices._1CApi.Models;
 8using Services.ApiServices._1CApi.OData;
 9using Services.ApiServices._1CApi.Stock;
 10using Services.BackgroundJobsServices;
 11using Services.DataServices;
 12using Services.StockSyncServices;
 13using Services.TransactionUtilsServices;
 14
 15namespace OzonOrdersWeb.Areas.Studio2.Helpers;
 16
 17public record TransitWarehouseDisplayInfo(
 18    string SenderWarehouse,
 19    string TransitWarehouse,
 20    bool FoundIn1C,
 21    int OrderCount);
 22
 023public record LostOrdersPreparationResult(
 024    List<Order> OrdersToProcess,
 025    string SkippedOrdersHtml,
 026    string OneCSuccessHtml,
 027    string CommentAppendix,
 028    List<Order>? OrdersForHistory,
 029    List<OneCResponse>? TransferResultForHistory,
 030    string? HistorySender,
 031    string? HistoryRecipient,
 032    DateTime HistoryWindowStart,
 033    bool AllOrdersSkipped,
 034    string? Fatal1CErrorHtml);
 35
 36public class LostByCommissionerTransitHelper
 37{
 38    public const string NoTransitStatusName = "Утерян (без транзита)";
 39    public const string LostStatusName = "Утерян реализатором";
 40    private const string NoTransitStatusColor = "#fd7e14";
 41
 42    private readonly ApplicationDbContext _context;
 43    private readonly AppStatusDataServices _appStatusServices;
 44    private readonly WarehouseMappingDataServices? _warehouseMappingDataServices;
 45    private readonly OneCTransferManager? _oneCTransferManager;
 46    private readonly IWarehouseStockLookupService _warehouseStockLookupService;
 47
 48    public LostByCommissionerTransitHelper(
 49        ApplicationDbContext context,
 50        AppStatusDataServices appStatusServices,
 51        WarehouseMappingDataServices? warehouseMappingDataServices,
 52        OneCTransferManager? oneCTransferManager,
 53        IWarehouseStockLookupService warehouseStockLookupService)
 54    {
 55        _context = context;
 56        _appStatusServices = appStatusServices;
 57        _warehouseMappingDataServices = warehouseMappingDataServices;
 58        _oneCTransferManager = oneCTransferManager;
 59        _warehouseStockLookupService = warehouseStockLookupService;
 60    }
 61
 62    public static TransactionConfig CreateLostTransactionConfig(TransactionManager transactionManager) => new()
 63    {
 64        TransactionType = TransactionType.LostByCommissioner,
 65        PrimaryStatusName = LostStatusName,
 66        ViewName = "CreateLostByCommissionerTransaction",
 67        RequiredSourceStatuses = new(),
 68        IncludeCancelledOrders = true,
 69        UseStatusTransitions = false,
 70        CreateTransactionMethod = (tm, orders, userName, createAt, comment) =>
 71            tm.CreateLostByCommissionerTransaction(orders, userName, createAt, comment)
 72    };
 73
 74    public async Task<List<TransitWarehouseDisplayInfo>> GetTransitWarehouseGroupsAsync(
 75        IReadOnlyList<Order> orders)
 76    {
 77        if (_warehouseMappingDataServices == null || _oneCTransferManager == null || orders.Count == 0)
 78            return new List<TransitWarehouseDisplayInfo>();
 79
 80        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 81        var ordersWithTransit = orders
 82            .Select(o => new
 83            {
 84                Resolution = ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings)
 85            })
 86            .ToList();
 87
 88        var groups = ordersWithTransit
 89            .GroupBy(x => (Sender: x.Resolution.SenderWarehouse ?? "", Transit: x.Resolution.TransitWarehouse ?? ""))
 90            .ToList();
 91
 92        var transitGroupTasks = groups.Select(async g =>
 93        {
 94            var sender = g.Key.Sender;
 95            var transitName = g.Key.Transit;
 96            var foundIn1C = !string.IsNullOrWhiteSpace(transitName) &&
 97                           await _oneCTransferManager.WarehouseExistsAsync(transitName);
 98            return new TransitWarehouseDisplayInfo(
 99                SenderWarehouse: string.IsNullOrWhiteSpace(sender) ? "не определён" : sender,
 100                TransitWarehouse: string.IsNullOrWhiteSpace(transitName) ? "не настроен" : transitName,
 101                FoundIn1C: foundIn1C,
 102                OrderCount: g.Count());
 103        });
 104
 105        return (await Task.WhenAll(transitGroupTasks)).ToList();
 106    }
 107
 108    public async Task<Dictionary<string, object>> BuildOrderInfoMapAsync(IReadOnlyList<Order> orders)
 109    {
 110        if (_warehouseMappingDataServices == null || orders.Count == 0)
 111            return new Dictionary<string, object>();
 112
 113        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 114        return orders.ToDictionary(
 115            o => o.Id.ToString(),
 116            o => (object)new
 117            {
 118                article = o.Article ?? "",
 119                manufacturer = o.EtProducer?.Name ?? "",
 120                senderWarehouse = ShippedToCommissionerWarehouseResolver.ResolveSenderWarehouse(o, mappings) ?? "",
 121                transitWarehouse = ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings).TransitWa
 122            });
 123    }
 124
 125    /// <summary>
 126    /// Строки для проверки остатков 1С по транзитному перемещению. Склад-отправитель резолвится тем
 127    /// же правилом, что и при создании документа.
 128    /// </summary>
 129    /// <summary>Карта «заказ → склад-отправитель» для предпросмотра остатков на странице.</summary>
 130    public async Task<Dictionary<int, string>> BuildStockCheckSenderMapAsync(IReadOnlyList<Order>? orders)
 131    {
 132        if (_warehouseMappingDataServices == null || orders == null)
 133            return new Dictionary<int, string>();
 134
 135        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 136        return TransactionStockCheckLineBuilder.BuildStockCheckSenderMap(orders, mappings);
 137    }
 138
 139    public async Task<IReadOnlyList<StockCheckLine>> BuildTransitStockCheckLinesAsync(
 140        IEnumerable<Order>? orders,
 141        IReadOnlyDictionary<int, decimal>? qtyOverrides)
 142    {
 143        if (_warehouseMappingDataServices == null || orders == null)
 144            return Array.Empty<StockCheckLine>();
 145
 146        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 147        // qtyOverrides здесь — остаток WS, который уходит в документ как есть (stockQtyOverrides
 148        // в TransferByClientAsync), кратность к нему не применяется.
 149        return TransactionStockCheckLineBuilder.FromWarehouseMappings(
 150            orders, mappings, orderSupplierMap: null, finalQtyOverrides: qtyOverrides);
 151    }
 152
 153    /// <summary>
 154    /// Заказы, которые уходят в документ 1С и в историю. Берём только перечитанные из БД:
 155    /// у пришедших с формы <see cref="Order.OzonClient"/> — заглушка с одним Id (без ИНН и имени),
 156    /// <see cref="Order.EtProducer"/> пустой, а Quantity равно нулю (признак утери), поэтому
 157    /// перемещение сорвалось бы на проверке ИНН и осталось без строк.
 158    /// </summary>
 159    public static (List<Order> All, List<Order> Normal, List<Order> Draft) SelectOrdersFor1C(
 160        IReadOnlyCollection<Order> ordersToProcess,
 161        IReadOnlyCollection<Order> ordersReloaded,
 162        IReadOnlySet<int>? blockingOrderIds)
 163    {
 164        var processIds = ordersToProcess.Select(o => o.Id).ToHashSet();
 165        var all = ordersReloaded.Where(o => processIds.Contains(o.Id)).ToList();
 166        var normal = all.Where(o => blockingOrderIds?.Contains(o.Id) != true).ToList();
 167        var draft = all.Where(o => blockingOrderIds?.Contains(o.Id) == true).ToList();
 168        return (all, normal, draft);
 169    }
 170
 171    public async Task<LostOrdersPreparationResult> PrepareLostOrdersAsync(
 172        List<Order>? formOrders,
 173        List<Order> ordersReloaded,
 174        Dictionary<int, string>? manufacturerByOrderId,
 175        bool processIn1C,
 176        string? oneCOrganizationInn,
 177        string userName,
 178        IReadOnlySet<int>? blockingOrderIds = null,
 179        (Dictionary<int, decimal> Overrides, HashSet<int> ZeroStockIds)? precomputedStock = null)
 180    {
 181        // Вызывающий код может уже посчитать остатки WS (например, чтобы прогнать их через
 182        // проверку остатков 1С) — тогда второй раз не считаем.
 183        var (stockQtyOverrides, zeroStockOrderIds) = precomputedStock
 184            ?? await BuildStockQtyAsync(ordersReloaded, manufacturerByOrderId);
 185
 186        var ordersToProcess = formOrders?.Where(o => !zeroStockOrderIds.Contains(o.Id)).ToList() ?? new();
 187        var ordersSkipped = ordersReloaded.Where(o => zeroStockOrderIds.Contains(o.Id)).ToList();
 188
 189        var skippedHtml = string.Empty;
 190        if (ordersSkipped.Count > 0)
 191        {
 192            var noTransitStatus = await GetOrCreateNoTransitStatusAsync();
 193            var skippedIds = ordersSkipped.Select(o => o.Id).ToList();
 194            await _context.Orders
 195                .Where(o => skippedIds.Contains(o.Id))
 196                .ExecuteUpdateAsync(s => s.SetProperty(o => o.AppStatusId, noTransitStatus.Id));
 197
 198            var nums = string.Join(", ", ordersSkipped
 199                .Select(o => o.ShipmentNumber)
 200                .Where(n => !string.IsNullOrEmpty(n))
 201                .Select(System.Net.WebUtility.HtmlEncode));
 202            skippedHtml =
 203                $"<div class='alert alert-warning mt-2'>" +
 204                $"<i class='fas fa-exclamation-triangle me-1'></i>" +
 205                $"<strong>Статус «{System.Net.WebUtility.HtmlEncode(NoTransitStatusName)}» ({ordersSkipped.Count} зак.):
 206                $"нет транзитного склада или остатка в WS — {nums}" +
 207                $"</div>";
 208        }
 209
 210        if (ordersToProcess.Count == 0)
 211        {
 212            return new LostOrdersPreparationResult(
 213                OrdersToProcess: ordersToProcess,
 214                SkippedOrdersHtml: skippedHtml,
 215                OneCSuccessHtml: string.Empty,
 216                CommentAppendix: string.Empty,
 217                OrdersForHistory: null,
 218                TransferResultForHistory: null,
 219                HistorySender: null,
 220                HistoryRecipient: null,
 221                HistoryWindowStart: DateTime.Now,
 222                AllOrdersSkipped: true,
 223                Fatal1CErrorHtml: null);
 224        }
 225
 226        var lostStatus = await GetOrCreateAppStatusAsync(LostStatusName);
 227        foreach (var order in ordersToProcess)
 228        {
 229            order.AppStatusId = lostStatus.Id;
 230            order.AppStatus = lostStatus;
 231        }
 232
 233        var historyWindowStart = DateTime.Now;
 234        List<Order>? ordersForHistory = null;
 235        List<OneCResponse>? transferResultForHistory = null;
 236        string? historySender = null;
 237        string? historyRecipient = null;
 238        var oneCSuccessHtml = string.Empty;
 239        var commentAppendix = string.Empty;
 240        string? fatal1CErrorHtml = null;
 241
 242        if (processIn1C && _oneCTransferManager != null && _warehouseMappingDataServices != null)
 243        {
 244            var (ordersFor1C, normalOrders, draftOrders) =
 245                SelectOrdersFor1C(ordersToProcess, ordersReloaded, blockingOrderIds);
 246            ordersForHistory = ordersFor1C;
 247
 248            if (ordersForHistory.Count == 0)
 249            {
 250                fatal1CErrorHtml =
 251                    "<div class='alert alert-danger'><strong>⚠️ Ошибка 1С (транзит)</strong><br/>" +
 252                    "Не удалось перечитать заказы из базы — перемещение не создано.</div>";
 253                return new LostOrdersPreparationResult(
 254                    OrdersToProcess: ordersToProcess,
 255                    SkippedOrdersHtml: skippedHtml,
 256                    OneCSuccessHtml: string.Empty,
 257                    CommentAppendix: string.Empty,
 258                    OrdersForHistory: null,
 259                    TransferResultForHistory: null,
 260                    HistorySender: null,
 261                    HistoryRecipient: null,
 262                    HistoryWindowStart: historyWindowStart,
 263                    AllOrdersSkipped: false,
 264                    Fatal1CErrorHtml: fatal1CErrorHtml);
 265            }
 266
 267            var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 268            var transitResolutions = ordersForHistory
 269                .Select(o => ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings))
 270                .ToList();
 271            var senderNames = transitResolutions.Select(r => r.SenderWarehouse)
 272                .Where(s => !string.IsNullOrWhiteSpace(s))
 273                .Distinct(StringComparer.OrdinalIgnoreCase)
 274                .ToList();
 275            historySender = senderNames.Any() ? string.Join(", ", senderNames) : null;
 276            var transitNames = transitResolutions.Select(r => r.TransitWarehouse)
 277                .Where(s => !string.IsNullOrWhiteSpace(s))
 278                .Distinct(StringComparer.OrdinalIgnoreCase)
 279                .ToList();
 280            historyRecipient = transitNames.Any() ? string.Join(", ", transitNames) : null;
 281
 282            var transferResult = new List<OneCResponse>();
 283            if (normalOrders.Any())
 284                transferResult.AddRange(await _oneCTransferManager.TransferStockToTransitWarehouses(
 285                    normalOrders, stockQtyOverrides.Count > 0 ? stockQtyOverrides : null,
 286                    documentDate: null, userName: userName, orgInnOverride: oneCOrganizationInn));
 287            if (draftOrders.Any())
 288                transferResult.AddRange(await _oneCTransferManager.TransferStockToTransitWarehouses(
 289                    draftOrders, stockQtyOverrides.Count > 0 ? stockQtyOverrides : null,
 290                    documentDate: null, userName: userName, orgInnOverride: oneCOrganizationInn,
 291                    createAsDraft: true,
 292                    draftReason: "недостаточно остатка в 1С. Проведите после оприходования товара."));
 293
 294            var failed = transferResult.Where(r => !r.Success).ToList();
 295            var postingFailed = transferResult
 296                .Where(r => !r.IsDraft)
 297                .Where(r => r.Success && !r.IsPosted && !string.IsNullOrWhiteSpace(r.PostingError))
 298                .ToList();
 299
 300            if (failed.Count > 0 && failed.Count == transferResult.Count)
 301            {
 302                var errorMsg = string.Join("; ", failed.Select(r => r.Message).Distinct().Take(3));
 303                fatal1CErrorHtml =
 304                    $"<div class='alert alert-danger'><strong>⚠️ Ошибка 1С (транзит)</strong><br/>{errorMsg}</div>";
 305                return new LostOrdersPreparationResult(
 306                    OrdersToProcess: ordersToProcess,
 307                    SkippedOrdersHtml: skippedHtml,
 308                    OneCSuccessHtml: string.Empty,
 309                    CommentAppendix: string.Empty,
 310                    OrdersForHistory: null,
 311                    TransferResultForHistory: null,
 312                    HistorySender: null,
 313                    HistoryRecipient: null,
 314                    HistoryWindowStart: historyWindowStart,
 315                    AllOrdersSkipped: false,
 316                    Fatal1CErrorHtml: fatal1CErrorHtml);
 317            }
 318
 319            if (postingFailed.Count > 0)
 320            {
 321                var errorMsg = string.Join("; ",
 322                    postingFailed
 323                        .Select(r => OneCODataErrorFormatter.Format(r.PostingError))
 324                        .Where(e => !string.IsNullOrWhiteSpace(e))
 325                        .Distinct()
 326                        .Take(3));
 327                fatal1CErrorHtml =
 328                    $"<div class='alert alert-danger'><strong>⚠️ Транзитное перемещение не проведено в 1С</strong><br/>{
 329                return new LostOrdersPreparationResult(
 330                    OrdersToProcess: ordersToProcess,
 331                    SkippedOrdersHtml: skippedHtml,
 332                    OneCSuccessHtml: OneCTransactionSummaryHelper.WrapSection(
 333                        "Утерян реализатором (транзит)",
 334                        OneCTransactionSummaryHelper.BuildAlertSummary(
 335                            ordersForHistory ?? new List<Order>(),
 336                            transferResult,
 337                            historySender,
 338                            historyRecipient)),
 339                    CommentAppendix: string.Empty,
 340                    OrdersForHistory: null,
 341                    TransferResultForHistory: null,
 342                    HistorySender: null,
 343                    HistoryRecipient: null,
 344                    HistoryWindowStart: historyWindowStart,
 345                    AllOrdersSkipped: false,
 346                    Fatal1CErrorHtml: fatal1CErrorHtml);
 347            }
 348
 349            transferResultForHistory = transferResult;
 350            oneCSuccessHtml = OneCTransactionSummaryHelper.WrapSection(
 351                "Проведённые документы 1С (транзит)",
 352                OneCTransactionSummaryHelper.BuildAlertSummary(normalOrders, transferResult.Where(r => !r.IsDraft).ToLis
 353                + OneCTransactionSummaryHelper.WrapSection(
 354                "Черновики 1С по дефицитным позициям (транзит)",
 355                OneCTransactionSummaryHelper.BuildAlertSummary(draftOrders, transferResult.Where(r => r.IsDraft).ToList(
 356            commentAppendix = OneCTransactionSummaryHelper.BuildCommentDetails(
 357                ordersForHistory,
 358                transferResult,
 359                historySender,
 360                historyRecipient);
 361        }
 362
 363        return new LostOrdersPreparationResult(
 364            OrdersToProcess: ordersToProcess,
 365            SkippedOrdersHtml: skippedHtml,
 366            OneCSuccessHtml: oneCSuccessHtml,
 367            CommentAppendix: commentAppendix,
 368            OrdersForHistory: ordersForHistory,
 369            TransferResultForHistory: transferResultForHistory,
 370            HistorySender: historySender,
 371            HistoryRecipient: historyRecipient,
 372            HistoryWindowStart: historyWindowStart,
 373            AllOrdersSkipped: false,
 374            Fatal1CErrorHtml: null);
 375    }
 376
 377    public async Task<(Dictionary<int, decimal> overrides, HashSet<int> zeroStockIds)> BuildStockQtyAsync(
 378        List<Order>? orders,
 379        Dictionary<int, string>? manufacturerByOrderId)
 380    {
 381        var overrides = new Dictionary<int, decimal>();
 382        var zeroStockIds = new HashSet<int>();
 383
 384        if (orders == null || orders.Count == 0 || _warehouseMappingDataServices == null)
 385            return (overrides, zeroStockIds);
 386
 387        var articles = orders
 388            .Where(o => !string.IsNullOrWhiteSpace(o.Article))
 389            .Select(o => o.Article!.Trim())
 390            .Distinct(StringComparer.OrdinalIgnoreCase)
 391            .ToList();
 392
 393        if (articles.Count == 0) return (overrides, zeroStockIds);
 394
 395        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 396        var snapshots = await _warehouseStockLookupService.LoadCandidatesByArticlesAsync(articles);
 397
 398        foreach (var order in orders)
 399        {
 400            if (string.IsNullOrWhiteSpace(order.Article))
 401            {
 402                zeroStockIds.Add(order.Id);
 403                continue;
 404            }
 405
 406            var senderWarehouse = ShippedToCommissionerWarehouseResolver
 407                .ResolveSenderWarehouse(order, mappings);
 408            if (string.IsNullOrWhiteSpace(senderWarehouse)) continue;
 409
 410            var manufacturerName = manufacturerByOrderId?.GetValueOrDefault(order.Id)
 411                ?? order.EtProducer?.Name
 412                ?? order.Manufacturer?.Name;
 413            var match = WarehouseStockLookupMatcher.FindMatch(
 414                snapshots,
 415                WarehouseStockLookupMatcher.Normalize(order.Article),
 416                WarehouseStockLookupMatcher.Normalize(manufacturerName),
 417                x => x.Article,
 418                x => x.Manufacturer);
 419
 420            if (match == null)
 421            {
 422                zeroStockIds.Add(order.Id);
 423                continue;
 424            }
 425
 426            var qty = GetStockQtyByWarehouseName(match.Item, senderWarehouse);
 427
 428            if (qty is > 0)
 429                overrides[order.Id] = qty.Value;
 430            else
 431                zeroStockIds.Add(order.Id);
 432        }
 433
 434        return (overrides, zeroStockIds);
 435    }
 436
 437    /// <summary>
 438    /// qty=0 в форме OrderedToSeller — только визуальный сигнал «утеря».
 439    /// Перед TransactOrder восстанавливаем фактическое количество из БД.
 440    /// </summary>
 441    public static void RestoreOriginalQuantities(List<Order> formOrders, IReadOnlyList<Order> ordersFromDb)
 442    {
 443        var qtyById = ordersFromDb.ToDictionary(o => o.Id, o => o.Quantity);
 444        foreach (var order in formOrders)
 445        {
 446            if (qtyById.TryGetValue(order.Id, out var qty))
 447                order.Quantity = qty;
 448        }
 449    }
 450
 451    public static string BuildTransferSummaryHtml(List<Order> orders, List<OneCResponse> results)
 452    {
 453        var successful = results.Where(r => r.Success).ToList();
 454        var failed = results.Where(r => !r.Success).ToList();
 455
 456        var sb = new System.Text.StringBuilder();
 457
 458        if (successful.Count > 0)
 459        {
 460            var alertClass = successful.Any(r => !string.IsNullOrWhiteSpace(r.MissingNomenclature))
 461                ? "alert-warning" : "alert-success";
 462            sb.Append($"<div class='alert {alertClass} mb-2'>");
 463            sb.Append("<strong>✅ Создано в 1С</strong><ul class='mb-0 mt-1'>");
 464            foreach (var r in successful)
 465            {
 466                var safeDocNum = string.IsNullOrWhiteSpace(r.DocumentNumber) ? ""
 467                    : $" <code>№{System.Net.WebUtility.HtmlEncode(r.DocumentNumber)}</code>";
 468                var draft = !string.IsNullOrWhiteSpace(r.MissingNomenclature) ? " <em>(черновик)</em>" : "";
 469                sb.Append($"<li>Перемещение{safeDocNum}{draft} — {System.Net.WebUtility.HtmlEncode(r.Message)}</li>");
 470            }
 471            sb.Append("</ul></div>");
 472        }
 473
 474        if (failed.Count > 0)
 475        {
 476            sb.Append("<div class='alert alert-danger mb-2'>");
 477            sb.Append("<strong>⚠️ Ошибки 1С</strong><ul class='mb-0 mt-1'>");
 478            foreach (var r in failed)
 479                sb.Append($"<li>{System.Net.WebUtility.HtmlEncode(r.Message)}</li>");
 480            sb.Append("</ul></div>");
 481        }
 482
 483        return sb.ToString();
 484    }
 485
 486    private async Task<AppStatus> GetOrCreateNoTransitStatusAsync()
 487    {
 488        var status = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = NoTransitStatusName });
 489        if (status != null) return status;
 490
 491        var color = await _context.StatusColors.FirstOrDefaultAsync(c => c.ColorCode == NoTransitStatusColor);
 492        if (color == null)
 493        {
 494            color = new StatusColor { ColorCode = NoTransitStatusColor };
 495            _context.StatusColors.Add(color);
 496        }
 497
 498        status = new AppStatus { Name = NoTransitStatusName, StatusColor = color };
 499        _context.AppStatuses.Add(status);
 500        await _context.SaveChangesAsync();
 501        return status;
 502    }
 503
 504    private async Task<AppStatus> GetOrCreateAppStatusAsync(string name)
 505    {
 506        var status = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = name });
 507        if (status != null) return status;
 508
 509        status = new AppStatus { Name = name };
 510        _context.AppStatuses.Add(status);
 511        await _context.SaveChangesAsync();
 512        return status;
 513    }
 514
 515    private static decimal? GetStockQtyByWarehouseName(
 516        WarehouseStockSnapshotCandidate stock, string warehouseName) =>
 517        warehouseName.Trim().ToUpperInvariant() switch
 518        {
 519            "M1" => stock.M1Qty,
 520            "M2" => stock.M2Qty,
 521            "M3" => stock.M3Qty,
 522            "B1" => stock.B1Qty,
 523            "B2" => stock.B2Qty,
 524            _ => null
 525        };
 526}