< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Helpers.LostByCommissionerTransitHelper
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Helpers/LostByCommissionerTransitHelper.cs
Line coverage
23%
Covered lines: 79
Uncovered lines: 255
Coverable lines: 334
Total lines: 526
Line coverage: 23.6%
Branch coverage
27%
Covered branches: 40
Total branches: 146
Branch coverage: 27.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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
 23public record LostOrdersPreparationResult(
 24    List<Order> OrdersToProcess,
 25    string SkippedOrdersHtml,
 26    string OneCSuccessHtml,
 27    string CommentAppendix,
 28    List<Order>? OrdersForHistory,
 29    List<OneCResponse>? TransferResultForHistory,
 30    string? HistorySender,
 31    string? HistoryRecipient,
 32    DateTime HistoryWindowStart,
 33    bool AllOrdersSkipped,
 34    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
 248    public LostByCommissionerTransitHelper(
 249        ApplicationDbContext context,
 250        AppStatusDataServices appStatusServices,
 251        WarehouseMappingDataServices? warehouseMappingDataServices,
 252        OneCTransferManager? oneCTransferManager,
 253        IWarehouseStockLookupService warehouseStockLookupService)
 54    {
 255        _context = context;
 256        _appStatusServices = appStatusServices;
 257        _warehouseMappingDataServices = warehouseMappingDataServices;
 258        _oneCTransferManager = oneCTransferManager;
 259        _warehouseStockLookupService = warehouseStockLookupService;
 260    }
 61
 062    public static TransactionConfig CreateLostTransactionConfig(TransactionManager transactionManager) => new()
 063    {
 064        TransactionType = TransactionType.LostByCommissioner,
 065        PrimaryStatusName = LostStatusName,
 066        ViewName = "CreateLostByCommissionerTransaction",
 067        RequiredSourceStatuses = new(),
 068        IncludeCancelledOrders = true,
 069        UseStatusTransitions = false,
 070        CreateTransactionMethod = (tm, orders, userName, createAt, comment) =>
 071            tm.CreateLostByCommissionerTransaction(orders, userName, createAt, comment)
 072    };
 73
 74    public async Task<List<TransitWarehouseDisplayInfo>> GetTransitWarehouseGroupsAsync(
 75        IReadOnlyList<Order> orders)
 76    {
 077        if (_warehouseMappingDataServices == null || _oneCTransferManager == null || orders.Count == 0)
 078            return new List<TransitWarehouseDisplayInfo>();
 79
 080        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 081        var ordersWithTransit = orders
 082            .Select(o => new
 083            {
 084                Resolution = ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings)
 085            })
 086            .ToList();
 87
 088        var groups = ordersWithTransit
 089            .GroupBy(x => (Sender: x.Resolution.SenderWarehouse ?? "", Transit: x.Resolution.TransitWarehouse ?? ""))
 090            .ToList();
 91
 092        var transitGroupTasks = groups.Select(async g =>
 093        {
 094            var sender = g.Key.Sender;
 095            var transitName = g.Key.Transit;
 096            var foundIn1C = !string.IsNullOrWhiteSpace(transitName) &&
 097                           await _oneCTransferManager.WarehouseExistsAsync(transitName);
 098            return new TransitWarehouseDisplayInfo(
 099                SenderWarehouse: string.IsNullOrWhiteSpace(sender) ? "не определён" : sender,
 0100                TransitWarehouse: string.IsNullOrWhiteSpace(transitName) ? "не настроен" : transitName,
 0101                FoundIn1C: foundIn1C,
 0102                OrderCount: g.Count());
 0103        });
 104
 0105        return (await Task.WhenAll(transitGroupTasks)).ToList();
 0106    }
 107
 108    public async Task<Dictionary<string, object>> BuildOrderInfoMapAsync(IReadOnlyList<Order> orders)
 109    {
 0110        if (_warehouseMappingDataServices == null || orders.Count == 0)
 0111            return new Dictionary<string, object>();
 112
 0113        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 0114        return orders.ToDictionary(
 0115            o => o.Id.ToString(),
 0116            o => (object)new
 0117            {
 0118                article = o.Article ?? "",
 0119                manufacturer = o.EtProducer?.Name ?? "",
 0120                senderWarehouse = ShippedToCommissionerWarehouseResolver.ResolveSenderWarehouse(o, mappings) ?? "",
 0121                transitWarehouse = ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings).TransitWa
 0122            });
 0123    }
 124
 125    /// <summary>
 126    /// Строки для проверки остатков 1С по транзитному перемещению. Склад-отправитель резолвится тем
 127    /// же правилом, что и при создании документа.
 128    /// </summary>
 129    /// <summary>Карта «заказ → склад-отправитель» для предпросмотра остатков на странице.</summary>
 130    public async Task<Dictionary<int, string>> BuildStockCheckSenderMapAsync(IReadOnlyList<Order>? orders)
 131    {
 0132        if (_warehouseMappingDataServices == null || orders == null)
 0133            return new Dictionary<int, string>();
 134
 0135        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 0136        return TransactionStockCheckLineBuilder.BuildStockCheckSenderMap(orders, mappings);
 0137    }
 138
 139    public async Task<IReadOnlyList<StockCheckLine>> BuildTransitStockCheckLinesAsync(
 140        IEnumerable<Order>? orders,
 141        IReadOnlyDictionary<int, decimal>? qtyOverrides)
 142    {
 0143        if (_warehouseMappingDataServices == null || orders == null)
 0144            return Array.Empty<StockCheckLine>();
 145
 0146        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 147        // qtyOverrides здесь — остаток WS, который уходит в документ как есть (stockQtyOverrides
 148        // в TransferByClientAsync), кратность к нему не применяется.
 0149        return TransactionStockCheckLineBuilder.FromWarehouseMappings(
 0150            orders, mappings, orderSupplierMap: null, finalQtyOverrides: qtyOverrides);
 0151    }
 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    {
 7164        var processIds = ordersToProcess.Select(o => o.Id).ToHashSet();
 7165        var all = ordersReloaded.Where(o => processIds.Contains(o.Id)).ToList();
 6166        var normal = all.Where(o => blockingOrderIds?.Contains(o.Id) != true).ToList();
 6167        var draft = all.Where(o => blockingOrderIds?.Contains(o.Id) == true).ToList();
 3168        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С) — тогда второй раз не считаем.
 0183        var (stockQtyOverrides, zeroStockOrderIds) = precomputedStock
 0184            ?? await BuildStockQtyAsync(ordersReloaded, manufacturerByOrderId);
 185
 0186        var ordersToProcess = formOrders?.Where(o => !zeroStockOrderIds.Contains(o.Id)).ToList() ?? new();
 0187        var ordersSkipped = ordersReloaded.Where(o => zeroStockOrderIds.Contains(o.Id)).ToList();
 188
 0189        var skippedHtml = string.Empty;
 0190        if (ordersSkipped.Count > 0)
 191        {
 0192            var noTransitStatus = await GetOrCreateNoTransitStatusAsync();
 0193            var skippedIds = ordersSkipped.Select(o => o.Id).ToList();
 0194            await _context.Orders
 0195                .Where(o => skippedIds.Contains(o.Id))
 0196                .ExecuteUpdateAsync(s => s.SetProperty(o => o.AppStatusId, noTransitStatus.Id));
 197
 0198            var nums = string.Join(", ", ordersSkipped
 0199                .Select(o => o.ShipmentNumber)
 0200                .Where(n => !string.IsNullOrEmpty(n))
 0201                .Select(System.Net.WebUtility.HtmlEncode));
 0202            skippedHtml =
 0203                $"<div class='alert alert-warning mt-2'>" +
 0204                $"<i class='fas fa-exclamation-triangle me-1'></i>" +
 0205                $"<strong>Статус «{System.Net.WebUtility.HtmlEncode(NoTransitStatusName)}» ({ordersSkipped.Count} зак.):
 0206                $"нет транзитного склада или остатка в WS — {nums}" +
 0207                $"</div>";
 0208        }
 209
 0210        if (ordersToProcess.Count == 0)
 211        {
 0212            return new LostOrdersPreparationResult(
 0213                OrdersToProcess: ordersToProcess,
 0214                SkippedOrdersHtml: skippedHtml,
 0215                OneCSuccessHtml: string.Empty,
 0216                CommentAppendix: string.Empty,
 0217                OrdersForHistory: null,
 0218                TransferResultForHistory: null,
 0219                HistorySender: null,
 0220                HistoryRecipient: null,
 0221                HistoryWindowStart: DateTime.Now,
 0222                AllOrdersSkipped: true,
 0223                Fatal1CErrorHtml: null);
 224        }
 225
 0226        var lostStatus = await GetOrCreateAppStatusAsync(LostStatusName);
 0227        foreach (var order in ordersToProcess)
 228        {
 0229            order.AppStatusId = lostStatus.Id;
 0230            order.AppStatus = lostStatus;
 231        }
 232
 0233        var historyWindowStart = DateTime.Now;
 0234        List<Order>? ordersForHistory = null;
 0235        List<OneCResponse>? transferResultForHistory = null;
 0236        string? historySender = null;
 0237        string? historyRecipient = null;
 0238        var oneCSuccessHtml = string.Empty;
 0239        var commentAppendix = string.Empty;
 0240        string? fatal1CErrorHtml = null;
 241
 0242        if (processIn1C && _oneCTransferManager != null && _warehouseMappingDataServices != null)
 243        {
 0244            var (ordersFor1C, normalOrders, draftOrders) =
 0245                SelectOrdersFor1C(ordersToProcess, ordersReloaded, blockingOrderIds);
 0246            ordersForHistory = ordersFor1C;
 247
 0248            if (ordersForHistory.Count == 0)
 249            {
 0250                fatal1CErrorHtml =
 0251                    "<div class='alert alert-danger'><strong>⚠️ Ошибка 1С (транзит)</strong><br/>" +
 0252                    "Не удалось перечитать заказы из базы — перемещение не создано.</div>";
 0253                return new LostOrdersPreparationResult(
 0254                    OrdersToProcess: ordersToProcess,
 0255                    SkippedOrdersHtml: skippedHtml,
 0256                    OneCSuccessHtml: string.Empty,
 0257                    CommentAppendix: string.Empty,
 0258                    OrdersForHistory: null,
 0259                    TransferResultForHistory: null,
 0260                    HistorySender: null,
 0261                    HistoryRecipient: null,
 0262                    HistoryWindowStart: historyWindowStart,
 0263                    AllOrdersSkipped: false,
 0264                    Fatal1CErrorHtml: fatal1CErrorHtml);
 265            }
 266
 0267            var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 0268            var transitResolutions = ordersForHistory
 0269                .Select(o => ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, mappings))
 0270                .ToList();
 0271            var senderNames = transitResolutions.Select(r => r.SenderWarehouse)
 0272                .Where(s => !string.IsNullOrWhiteSpace(s))
 0273                .Distinct(StringComparer.OrdinalIgnoreCase)
 0274                .ToList();
 0275            historySender = senderNames.Any() ? string.Join(", ", senderNames) : null;
 0276            var transitNames = transitResolutions.Select(r => r.TransitWarehouse)
 0277                .Where(s => !string.IsNullOrWhiteSpace(s))
 0278                .Distinct(StringComparer.OrdinalIgnoreCase)
 0279                .ToList();
 0280            historyRecipient = transitNames.Any() ? string.Join(", ", transitNames) : null;
 281
 0282            var transferResult = new List<OneCResponse>();
 0283            if (normalOrders.Any())
 0284                transferResult.AddRange(await _oneCTransferManager.TransferStockToTransitWarehouses(
 0285                    normalOrders, stockQtyOverrides.Count > 0 ? stockQtyOverrides : null,
 0286                    documentDate: null, userName: userName, orgInnOverride: oneCOrganizationInn));
 0287            if (draftOrders.Any())
 0288                transferResult.AddRange(await _oneCTransferManager.TransferStockToTransitWarehouses(
 0289                    draftOrders, stockQtyOverrides.Count > 0 ? stockQtyOverrides : null,
 0290                    documentDate: null, userName: userName, orgInnOverride: oneCOrganizationInn,
 0291                    createAsDraft: true,
 0292                    draftReason: "недостаточно остатка в 1С. Проведите после оприходования товара."));
 293
 0294            var failed = transferResult.Where(r => !r.Success).ToList();
 0295            var postingFailed = transferResult
 0296                .Where(r => !r.IsDraft)
 0297                .Where(r => r.Success && !r.IsPosted && !string.IsNullOrWhiteSpace(r.PostingError))
 0298                .ToList();
 299
 0300            if (failed.Count > 0 && failed.Count == transferResult.Count)
 301            {
 0302                var errorMsg = string.Join("; ", failed.Select(r => r.Message).Distinct().Take(3));
 0303                fatal1CErrorHtml =
 0304                    $"<div class='alert alert-danger'><strong>⚠️ Ошибка 1С (транзит)</strong><br/>{errorMsg}</div>";
 0305                return new LostOrdersPreparationResult(
 0306                    OrdersToProcess: ordersToProcess,
 0307                    SkippedOrdersHtml: skippedHtml,
 0308                    OneCSuccessHtml: string.Empty,
 0309                    CommentAppendix: string.Empty,
 0310                    OrdersForHistory: null,
 0311                    TransferResultForHistory: null,
 0312                    HistorySender: null,
 0313                    HistoryRecipient: null,
 0314                    HistoryWindowStart: historyWindowStart,
 0315                    AllOrdersSkipped: false,
 0316                    Fatal1CErrorHtml: fatal1CErrorHtml);
 317            }
 318
 0319            if (postingFailed.Count > 0)
 320            {
 0321                var errorMsg = string.Join("; ",
 0322                    postingFailed
 0323                        .Select(r => OneCODataErrorFormatter.Format(r.PostingError))
 0324                        .Where(e => !string.IsNullOrWhiteSpace(e))
 0325                        .Distinct()
 0326                        .Take(3));
 0327                fatal1CErrorHtml =
 0328                    $"<div class='alert alert-danger'><strong>⚠️ Транзитное перемещение не проведено в 1С</strong><br/>{
 0329                return new LostOrdersPreparationResult(
 0330                    OrdersToProcess: ordersToProcess,
 0331                    SkippedOrdersHtml: skippedHtml,
 0332                    OneCSuccessHtml: OneCTransactionSummaryHelper.WrapSection(
 0333                        "Утерян реализатором (транзит)",
 0334                        OneCTransactionSummaryHelper.BuildAlertSummary(
 0335                            ordersForHistory ?? new List<Order>(),
 0336                            transferResult,
 0337                            historySender,
 0338                            historyRecipient)),
 0339                    CommentAppendix: string.Empty,
 0340                    OrdersForHistory: null,
 0341                    TransferResultForHistory: null,
 0342                    HistorySender: null,
 0343                    HistoryRecipient: null,
 0344                    HistoryWindowStart: historyWindowStart,
 0345                    AllOrdersSkipped: false,
 0346                    Fatal1CErrorHtml: fatal1CErrorHtml);
 347            }
 348
 0349            transferResultForHistory = transferResult;
 0350            oneCSuccessHtml = OneCTransactionSummaryHelper.WrapSection(
 0351                "Проведённые документы 1С (транзит)",
 0352                OneCTransactionSummaryHelper.BuildAlertSummary(normalOrders, transferResult.Where(r => !r.IsDraft).ToLis
 0353                + OneCTransactionSummaryHelper.WrapSection(
 0354                "Черновики 1С по дефицитным позициям (транзит)",
 0355                OneCTransactionSummaryHelper.BuildAlertSummary(draftOrders, transferResult.Where(r => r.IsDraft).ToList(
 0356            commentAppendix = OneCTransactionSummaryHelper.BuildCommentDetails(
 0357                ordersForHistory,
 0358                transferResult,
 0359                historySender,
 0360                historyRecipient);
 0361        }
 362
 0363        return new LostOrdersPreparationResult(
 0364            OrdersToProcess: ordersToProcess,
 0365            SkippedOrdersHtml: skippedHtml,
 0366            OneCSuccessHtml: oneCSuccessHtml,
 0367            CommentAppendix: commentAppendix,
 0368            OrdersForHistory: ordersForHistory,
 0369            TransferResultForHistory: transferResultForHistory,
 0370            HistorySender: historySender,
 0371            HistoryRecipient: historyRecipient,
 0372            HistoryWindowStart: historyWindowStart,
 0373            AllOrdersSkipped: false,
 0374            Fatal1CErrorHtml: null);
 0375    }
 376
 377    public async Task<(Dictionary<int, decimal> overrides, HashSet<int> zeroStockIds)> BuildStockQtyAsync(
 378        List<Order>? orders,
 379        Dictionary<int, string>? manufacturerByOrderId)
 380    {
 2381        var overrides = new Dictionary<int, decimal>();
 2382        var zeroStockIds = new HashSet<int>();
 383
 2384        if (orders == null || orders.Count == 0 || _warehouseMappingDataServices == null)
 0385            return (overrides, zeroStockIds);
 386
 2387        var articles = orders
 2388            .Where(o => !string.IsNullOrWhiteSpace(o.Article))
 2389            .Select(o => o.Article!.Trim())
 2390            .Distinct(StringComparer.OrdinalIgnoreCase)
 2391            .ToList();
 392
 2393        if (articles.Count == 0) return (overrides, zeroStockIds);
 394
 2395        var mappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 2396        var snapshots = await _warehouseStockLookupService.LoadCandidatesByArticlesAsync(articles);
 397
 8398        foreach (var order in orders)
 399        {
 2400            if (string.IsNullOrWhiteSpace(order.Article))
 401            {
 0402                zeroStockIds.Add(order.Id);
 0403                continue;
 404            }
 405
 2406            var senderWarehouse = ShippedToCommissionerWarehouseResolver
 2407                .ResolveSenderWarehouse(order, mappings);
 2408            if (string.IsNullOrWhiteSpace(senderWarehouse)) continue;
 409
 2410            var manufacturerName = manufacturerByOrderId?.GetValueOrDefault(order.Id)
 2411                ?? order.EtProducer?.Name
 2412                ?? order.Manufacturer?.Name;
 2413            var match = WarehouseStockLookupMatcher.FindMatch(
 2414                snapshots,
 2415                WarehouseStockLookupMatcher.Normalize(order.Article),
 2416                WarehouseStockLookupMatcher.Normalize(manufacturerName),
 2417                x => x.Article,
 4418                x => x.Manufacturer);
 419
 2420            if (match == null)
 421            {
 0422                zeroStockIds.Add(order.Id);
 0423                continue;
 424            }
 425
 2426            var qty = GetStockQtyByWarehouseName(match.Item, senderWarehouse);
 427
 2428            if (qty is > 0)
 1429                overrides[order.Id] = qty.Value;
 430            else
 1431                zeroStockIds.Add(order.Id);
 432        }
 433
 2434        return (overrides, zeroStockIds);
 2435    }
 436
 437    /// <summary>
 438    /// qty=0 в форме OrderedToSeller — только визуальный сигнал «утеря».
 439    /// Перед TransactOrder восстанавливаем фактическое количество из БД.
 440    /// </summary>
 441    public static void RestoreOriginalQuantities(List<Order> formOrders, IReadOnlyList<Order> ordersFromDb)
 442    {
 6443        var qtyById = ordersFromDb.ToDictionary(o => o.Id, o => o.Quantity);
 10444        foreach (var order in formOrders)
 445        {
 3446            if (qtyById.TryGetValue(order.Id, out var qty))
 2447                order.Quantity = qty;
 448        }
 2449    }
 450
 451    public static string BuildTransferSummaryHtml(List<Order> orders, List<OneCResponse> results)
 452    {
 3453        var successful = results.Where(r => r.Success).ToList();
 3454        var failed = results.Where(r => !r.Success).ToList();
 455
 1456        var sb = new System.Text.StringBuilder();
 457
 1458        if (successful.Count > 0)
 459        {
 2460            var alertClass = successful.Any(r => !string.IsNullOrWhiteSpace(r.MissingNomenclature))
 1461                ? "alert-warning" : "alert-success";
 1462            sb.Append($"<div class='alert {alertClass} mb-2'>");
 1463            sb.Append("<strong>✅ Создано в 1С</strong><ul class='mb-0 mt-1'>");
 4464            foreach (var r in successful)
 465            {
 1466                var safeDocNum = string.IsNullOrWhiteSpace(r.DocumentNumber) ? ""
 1467                    : $" <code>№{System.Net.WebUtility.HtmlEncode(r.DocumentNumber)}</code>";
 1468                var draft = !string.IsNullOrWhiteSpace(r.MissingNomenclature) ? " <em>(черновик)</em>" : "";
 1469                sb.Append($"<li>Перемещение{safeDocNum}{draft} — {System.Net.WebUtility.HtmlEncode(r.Message)}</li>");
 470            }
 1471            sb.Append("</ul></div>");
 472        }
 473
 1474        if (failed.Count > 0)
 475        {
 1476            sb.Append("<div class='alert alert-danger mb-2'>");
 1477            sb.Append("<strong>⚠️ Ошибки 1С</strong><ul class='mb-0 mt-1'>");
 4478            foreach (var r in failed)
 1479                sb.Append($"<li>{System.Net.WebUtility.HtmlEncode(r.Message)}</li>");
 1480            sb.Append("</ul></div>");
 481        }
 482
 1483        return sb.ToString();
 484    }
 485
 486    private async Task<AppStatus> GetOrCreateNoTransitStatusAsync()
 487    {
 0488        var status = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = NoTransitStatusName });
 0489        if (status != null) return status;
 490
 0491        var color = await _context.StatusColors.FirstOrDefaultAsync(c => c.ColorCode == NoTransitStatusColor);
 0492        if (color == null)
 493        {
 0494            color = new StatusColor { ColorCode = NoTransitStatusColor };
 0495            _context.StatusColors.Add(color);
 496        }
 497
 0498        status = new AppStatus { Name = NoTransitStatusName, StatusColor = color };
 0499        _context.AppStatuses.Add(status);
 0500        await _context.SaveChangesAsync();
 0501        return status;
 0502    }
 503
 504    private async Task<AppStatus> GetOrCreateAppStatusAsync(string name)
 505    {
 0506        var status = await _appStatusServices.GetAppStatusAsync(new AppStatus { Name = name });
 0507        if (status != null) return status;
 508
 0509        status = new AppStatus { Name = name };
 0510        _context.AppStatuses.Add(status);
 0511        await _context.SaveChangesAsync();
 0512        return status;
 0513    }
 514
 515    private static decimal? GetStockQtyByWarehouseName(
 516        WarehouseStockSnapshotCandidate stock, string warehouseName) =>
 2517        warehouseName.Trim().ToUpperInvariant() switch
 2518        {
 0519            "M1" => stock.M1Qty,
 2520            "M2" => stock.M2Qty,
 0521            "M3" => stock.M3Qty,
 0522            "B1" => stock.B1Qty,
 0523            "B2" => stock.B2Qty,
 0524            _ => null
 2525        };
 526}