< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Helpers.OneCTransactionSummaryHelper
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Helpers/OneCTransactionSummaryHelper.cs
Line coverage
86%
Covered lines: 346
Uncovered lines: 55
Coverable lines: 401
Total lines: 619
Line coverage: 86.2%
Branch coverage
65%
Covered branches: 199
Total branches: 304
Branch coverage: 65.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
WrapSection(...)62.5%8890%
BuildAlertSummary(...)57.54%26410675.88%
ResolveProducts(...)66.66%66100%
Plural(...)75%121290.9%
FormatQty(...)50%22100%
BuildCountsLine(...)100%22100%
FormatProduct(...)100%11100%
BuildDraftAlertSummary(...)61.11%363693.93%
BuildPostedAlertSummary(...)94.73%3838100%
H(...)50%22100%
BuildCommentDetails(...)80%303092.75%
BuildRequestSummary(...)54.16%534886.84%
GetEffectiveWarehouse(...)57.14%211466.66%

File(s)

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

#LineLine coverage
 1using System.Net;
 2using System.Text;
 3using OzonDomains.Models;
 4using Services.ApiServices._1CApi.Models;
 5using Services.ApiServices._1CApi.OData;
 6
 7namespace OzonOrdersWeb.Areas.Studio2.Helpers;
 8
 9/// <summary>
 10/// Формирует краткую сводку для алерта и полную детализацию для комментария транзакции 1С.
 11/// </summary>
 12public static class OneCTransactionSummaryHelper
 13{
 14    public static string WrapSection(string sectionTitle, string innerHtml)
 15    {
 216        if (string.IsNullOrWhiteSpace(innerHtml))
 117            return string.Empty;
 18
 119        if (sectionTitle is "Проведённые документы 1С" or "Черновики 1С по дефицитным позициям")
 020            return innerHtml;
 21
 122        var sb = new StringBuilder();
 123        sb.Append("<div class='mb-2'>");
 124        sb.Append($"<div class='fw-semibold small text-muted mb-1'>{H(sectionTitle)}</div>");
 125        sb.Append(innerHtml);
 126        sb.Append("</div>");
 127        return sb.ToString();
 28    }
 29
 30    /// <summary>
 31    /// Краткая сводка для алерта: склады, количество товаров, номер документа 1С, GUID.
 32    /// </summary>
 33    public static string BuildAlertSummary(
 34        List<Order> ordersToTransfer,
 35        List<OneCResponse> transferResult,
 36        string? warehouseSender = null,
 37        string? warehouseRecipient = null)
 38    {
 1939        transferResult ??= new List<OneCResponse>();
 4140        var successful = transferResult.Where(r => r.Success).ToList();
 4141        var failed = transferResult.Where(r => !r.Success).ToList();
 42
 1943        var sb = new StringBuilder();
 44
 1945        var draftsWithMissing = successful
 2046            .Where(r => r.IsDraft || !string.IsNullOrWhiteSpace(r.MissingNomenclature))
 1947            .ToList();
 1948        var postingFailures = successful
 2049            .Where(r => !r.IsPosted && !string.IsNullOrWhiteSpace(r.PostingError))
 1950            .ToList();
 51
 1952        if (draftsWithMissing.Any())
 353            return BuildDraftAlertSummary(ordersToTransfer, successful, draftsWithMissing, warehouseSender, warehouseRec
 54
 2955        if (successful.Any() && failed.Count == 0 && successful.All(r => r.IsPosted))
 856            return BuildPostedAlertSummary(ordersToTransfer, successful, warehouseSender, warehouseRecipient);
 57
 858        if (successful.Any())
 59        {
 1260            var allPosted = successful.All(r => r.IsPosted);
 661            var alertClass = postingFailures.Any() || draftsWithMissing.Any()
 662                ? "alert-warning"
 663                : allPosted ? "alert-success" : "alert-warning";
 664            var title = draftsWithMissing.Any()
 665                ? "⚠ Создано в 1С как черновик"
 666                : postingFailures.Any()
 667                    ? "⚠ Создано в 1С, проведение не выполнено"
 668                    : allPosted
 669                        ? "✅ Успешно создано в 1С"
 670                        : "⚠ Создано в 1С, проведение не выполнено";
 671            sb.Append($"<div class='alert {alertClass} mb-2'>");
 672            sb.Append($"<strong>{title}</strong>");
 673            sb.Append("<ul class='list-unstyled mb-0 small text-secondary mt-1'>");
 74
 75            // Организация
 676            var orgs = ordersToTransfer
 077                .Select(o => o.OzonClient?.Name?.Trim())
 078                .Where(x => !string.IsNullOrEmpty(x))
 679                .Distinct(StringComparer.OrdinalIgnoreCase)
 680                .ToList();
 681            var inns = ordersToTransfer
 082                .Select(o => o.OzonClient?.INNCode?.Trim())
 083                .Where(x => !string.IsNullOrEmpty(x))
 684                .Distinct(StringComparer.OrdinalIgnoreCase)
 685                .ToList();
 686            if (orgs.Any())
 87            {
 088                var innPart = inns.Any() ? $" (ИНН {H(string.Join(", ", inns))})" : "";
 089                sb.Append($"<li><span class='text-muted'>Орг.:</span> {H(string.Join(", ", orgs))}{innPart}</li>");
 90            }
 91
 92            // Отправитель (поставщик)
 693            var senders = ordersToTransfer
 094                .Select(o => o.Supplier?.Name?.Trim())
 095                .Where(x => !string.IsNullOrEmpty(x))
 696                .Distinct(StringComparer.OrdinalIgnoreCase)
 697                .ToList();
 698            if (senders.Any())
 099                sb.Append($"<li><span class='text-muted'>Отпр.:</span> {H(string.Join(", ", senders))}</li>");
 6100            else if (ordersToTransfer.Any())
 0101                sb.Append("<li><span class='text-muted'>Отпр.:</span> Не указан</li>");
 102
 103            // Склад отправителя
 6104            var senderWarehouses = !string.IsNullOrEmpty(warehouseSender)
 6105                ? new List<string> { warehouseSender.Trim() }
 6106                : ordersToTransfer.Select(GetEffectiveWarehouse)
 6107                    .Where(x => !string.IsNullOrEmpty(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 6108            if (senderWarehouses.Any())
 0109                sb.Append($"<li><span class='text-muted'>Склад отпр.:</span> {H(string.Join(", ", senderWarehouses))}</l
 110
 111            // Склад получателя
 6112            var recipientWarehouses = !string.IsNullOrEmpty(warehouseRecipient)
 6113                ? new List<string> { warehouseRecipient.Trim() }
 0114                : ordersToTransfer.Select(o => o.OzonClient?.WarehouseName?.Trim())
 0115                    .Where(x => !string.IsNullOrEmpty(x))
 0116                    .Select(x => x!)
 6117                    .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 6118            if (recipientWarehouses.Any())
 0119                sb.Append($"<li><span class='text-muted'>Склад получ.:</span> {H(string.Join(", ", recipientWarehouses))
 120
 121            // Заказы / товары / артикулы
 6122            var productGroups = ResolveProducts(ordersToTransfer, successful);
 123
 6124            if (productGroups.Any())
 125            {
 0126                var articleParts = productGroups.Take(4).Select(FormatProduct).ToList();
 0127                var remaining = productGroups.Count - articleParts.Count;
 0128                var articlesStr = string.Join(", ", articleParts) + (remaining > 0 ? $" +{remaining}" : "");
 0129                sb.Append($"<li>{BuildCountsLine(ordersToTransfer.Count, productGroups)} — {articlesStr}</li>");
 130            }
 131
 132            // Документов + IsPosted badge
 6133            var postedBadge = allPosted
 6134                ? "<span class='badge bg-success-subtle text-success ms-1'>✔ проведено</span>"
 6135                : draftsWithMissing.Any()
 6136                    ? "<span class='badge bg-warning-subtle text-warning ms-1'>⚠ черновик</span>"
 6137                    : "<span class='badge bg-warning-subtle text-warning ms-1'>⚠ не проведено</span>";
 6138            sb.Append($"<li>{Plural(successful.Count, "документ", "документа", "документов")} {postedBadge}</li>");
 139
 6140            if (draftsWithMissing.Any())
 141            {
 0142                var missingList = draftsWithMissing
 0143                    .Select(d => d.MissingNomenclature)
 0144                    .Where(m => !string.IsNullOrWhiteSpace(m))
 0145                    .Distinct()
 0146                    .ToList();
 147                // Проверяем есть ли документы где ВСЕ позиции без номенклатуры (пустые строки)
 0148                var allMissingDocs = draftsWithMissing.Where(r => !r.IsPosted).ToList();
 0149                if (allMissingDocs.Count == successful.Count && missingList.Any())
 0150                    sb.Append("<li class='text-warning-emphasis fw-semibold'>⚠ Все позиции отсутствуют в номенклатуре 1С
 0151                if (missingList.Any())
 152                {
 0153                    sb.Append("<li class='text-warning-emphasis'><span class='text-muted'>Без номенклатуры:</span> ");
 0154                    sb.Append(H(string.Join("; ", missingList)));
 0155                    sb.Append("</li>");
 156                }
 157            }
 158
 6159            var createdNomenclature = successful
 8160                .Select(r => r.CreatedNomenclature)
 8161                .Where(x => !string.IsNullOrWhiteSpace(x))
 6162                .Distinct()
 6163                .ToList();
 6164            if (createdNomenclature.Any())
 0165                sb.Append($"<li class='text-info-emphasis'><span class='text-muted'>Создана номенклатура 1С:</span> {H(s
 166
 167            // Номера документов (читаемые) — со ссылками на страницу документа
 6168            var docLinks = successful
 8169                .Where(s => !string.IsNullOrWhiteSpace(s.DocumentNumber) || !string.IsNullOrWhiteSpace(s.TransactionId))
 2170                .DistinctBy(s => s.TransactionId ?? s.DocumentNumber)
 6171                .Take(5)
 6172                .Select(s =>
 6173                {
 2174                    var label = !string.IsNullOrWhiteSpace(s.DocumentNumber) ? s.DocumentNumber : s.TransactionId;
 2175                    if (!string.IsNullOrWhiteSpace(s.TransactionId) && Guid.TryParse(s.TransactionId, out _))
 0176                        return $"<a href='/Studio2/OneCDocument/Details?refKey={H(s.TransactionId)}' target='_blank'>{H(
 2177                    return H(label);
 6178                })
 6179                .ToList();
 6180            if (docLinks.Any())
 2181                sb.Append($"<li class='fw-medium text-dark'>{string.Join(", ", docLinks)}</li>");
 182
 183            // Ошибки проведения (например, нехватка остатков на складе 1С)
 6184            if (postingFailures.Any())
 185            {
 4186                sb.Append("<li class='text-danger fw-semibold mt-1'>⛔ Ошибка проведения в 1С:</li>");
 24187                foreach (var group in postingFailures.GroupBy(r => OneCODataErrorFormatter.Format(r.PostingError)))
 188                {
 5189                    var err = OneCResponseErrors.AppendOneCDetails(group.Key, group) ?? group.Key;
 5190                    var docLabels = group
 6191                        .Select(r => !string.IsNullOrWhiteSpace(r.DocumentNumber)
 6192                            ? $"№{r.DocumentNumber}"
 6193                            : !string.IsNullOrWhiteSpace(r.TransactionId)
 6194                                ? r.TransactionId
 6195                                : "документ")
 5196                        .Distinct()
 5197                        .ToList();
 5198                    var docLabel = string.Join(", ", docLabels);
 5199                    sb.Append($"<li class='text-danger small'><span class='fw-medium'>{H(docLabel)}:</span> {H(err)}</li
 200                }
 201            }
 202
 203            // GUID (для поиска / перехода в 1С)
 6204            var guids = successful
 8205                .Select(s => s.TransactionId)
 8206                .Where(id => !string.IsNullOrWhiteSpace(id))
 6207                .Distinct().Take(5).ToList();
 6208            if (guids.Any())
 0209                sb.Append($"<li class='font-monospace text-muted user-select-all' style='font-size:0.78em'>{H(string.Joi
 210
 6211            sb.Append("</ul></div>");
 212        }
 213
 8214        if (failed.Any())
 215        {
 2216            sb.Append("<div class='alert alert-warning'>");
 2217            sb.Append("<strong>⚠️ С ошибками</strong><br/>");
 2218            sb.Append($"Ошибок: {failed.Count}<br/>");
 4219            var errors = failed.Select(f => f.Message).Distinct().Take(2);
 2220            sb.Append($"<small>{H(string.Join("; ", errors))}</small>");
 2221            if (failed.Count > 2) sb.Append("...");
 2222            sb.Append("</div>");
 223        }
 224
 8225        return sb.ToString();
 226    }
 227
 228    /// <summary>
 229    /// Позиции для сводки. Приоритет — строки, реально ушедшие в документ 1С: при утере
 230    /// реализатором в транзит уходит остаток склада, а не Quantity заказа, и показывать «×1»
 231    /// там, где в 1С записано 9, нельзя. Если строк в ответе нет (старые вызовы, ошибки
 232    /// создания документа) — считаем по заказам.
 233    /// </summary>
 234    private static List<(string Article, decimal Qty)> ResolveProducts(
 235        List<Order> orders,
 236        List<OneCResponse> successful)
 237    {
 17238        var fromDocument = successful
 20239            .Where(r => r.DocumentLines != null)
 1240            .SelectMany(r => r.DocumentLines!)
 1241            .Where(l => !string.IsNullOrWhiteSpace(l.Article) && l.Quantity > 0)
 1242            .GroupBy(l => l.Article.Trim(), StringComparer.OrdinalIgnoreCase)
 2243            .Select(g => (Article: g.Key, Qty: g.Sum(l => l.Quantity)))
 1244            .OrderByDescending(x => x.Qty)
 17245            .ToList();
 17246        if (fromDocument.Count > 0)
 1247            return fromDocument;
 248
 16249        return orders
 6250            .Where(o => !string.IsNullOrWhiteSpace(o.Article) && o.Quantity > 0)
 6251            .GroupBy(o => o.Article!.Trim(), StringComparer.OrdinalIgnoreCase)
 12252            .Select(g => (Article: g.Key, Qty: (decimal)g.Sum(o => o.Quantity ?? 0)))
 6253            .OrderByDescending(x => x.Qty)
 16254            .ToList();
 255    }
 256
 257    /// <summary>«1 заказ», «2 заказа», «5 заказов».</summary>
 258    private static string Plural(decimal value, string one, string few, string many)
 259    {
 20260        var text = FormatQty(value);
 20261        if (value != Math.Truncate(value))
 0262            return $"{text} {many}";
 263
 20264        var n = (long)Math.Abs(Math.Truncate(value));
 20265        var mod100 = n % 100;
 20266        var mod10 = n % 10;
 20267        var word = mod100 is >= 11 and <= 14 ? many
 20268            : mod10 == 1 ? one
 20269            : mod10 is >= 2 and <= 4 ? few
 20270            : many;
 20271        return $"{text} {word}";
 272    }
 273
 274    private static string FormatQty(decimal value) =>
 31275        value == Math.Truncate(value)
 31276            ? ((long)value).ToString()
 31277            : value.ToString("0.###");
 278
 279    /// <summary>«1 заказ · 1 позиция · 9 шт» — без «заказ(ов)» и прочих скобок.</summary>
 280    private static string BuildCountsLine(int ordersCount, List<(string Article, decimal Qty)> products)
 281    {
 11282        var parts = new List<string> { Plural(ordersCount, "заказ", "заказа", "заказов") };
 11283        if (products.Count > 0)
 284        {
 3285            parts.Add(Plural(products.Count, "позиция", "позиции", "позиций"));
 10286            parts.Add($"{FormatQty(products.Sum(p => p.Qty))} шт");
 287        }
 288
 11289        return string.Join(" · ", parts);
 290    }
 291
 292    private static string FormatProduct((string Article, decimal Qty) product) =>
 7293        $"{H(product.Article)} × {FormatQty(product.Qty)}";
 294
 295    private static string BuildDraftAlertSummary(
 296        List<Order> orders,
 297        List<OneCResponse> successful,
 298        List<OneCResponse> drafts,
 299        string? warehouseSender,
 300        string? warehouseRecipient)
 301    {
 3302        var organization = orders.Select(o => o.OzonClient?.Name?.Trim()).FirstOrDefault(x => !string.IsNullOrWhiteSpace
 3303        var sender = !string.IsNullOrWhiteSpace(warehouseSender)
 3304            ? warehouseSender.Trim()
 3305            : orders.Select(GetEffectiveWarehouse).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "—";
 3306        var recipient = !string.IsNullOrWhiteSpace(warehouseRecipient)
 3307            ? warehouseRecipient.Trim()
 3308            : orders.Select(o => o.OzonClient?.WarehouseName?.Trim()).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))
 3309        var products = ResolveProducts(orders, successful);
 7310        var firstDocument = successful.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.DocumentNumber) || !string.IsNul
 3311        var documentLabel = firstDocument?.DocumentNumber ?? "Черновик 1С";
 3312        var documentLink = firstDocument?.TransactionId is { } transactionId && Guid.TryParse(transactionId, out _)
 3313            ? $"<a class='onec-draft-document-link' href='/Studio2/OneCDocument/Details?refKey={H(transactionId)}' targe
 3314            : H(documentLabel);
 11315        var missing = drafts.Select(d => d.MissingNomenclature).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToL
 11316        var guids = successful.Select(s => s.TransactionId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().Take(5)
 317
 3318        var sb = new StringBuilder();
 3319        sb.Append("<div class='alert alert-warning mb-2 onec-draft-summary'>");
 3320        sb.Append("<div class='d-flex align-items-start justify-content-between gap-2'>");
 3321        sb.Append("<div><strong>Требуется действие по документу 1С</strong><div class='small text-warning-emphasis'>Черн
 3322        sb.Append("<span class='badge bg-warning-subtle text-warning'>Черновик</span></div>");
 3323        sb.Append($"<div class='onec-draft-document mt-2'>{documentLink}</div>");
 3324        sb.Append($"<div class='small text-secondary'>{H(organization)} · {H(sender)} → {H(recipient)}</div>");
 3325        sb.Append($"<div class='small mt-2'>{BuildCountsLine(orders.Count, products)}</div>");
 3326        if (products.Any())
 0327            sb.Append($"<div class='small text-secondary'>{string.Join(" · ", products.Take(3).Select(FormatProduct))}{(
 3328        sb.Append("<details class='mt-2'><summary class='small fw-semibold'>Подробнее</summary><div class='small mt-2'>"
 3329        sb.Append("<div class='fw-semibold'>Что нужно сделать</div><div class='text-secondary'>Проведите приход в 1С, за
 3330        if (missing.Any())
 2331            sb.Append($"<div class='mt-2'><span class='fw-semibold'>Позиции без номенклатуры:</span> {H(string.Join("; "
 3332        if (guids.Any())
 0333            sb.Append($"<div class='mt-2 text-muted'>ID 1С: <span class='font-monospace user-select-all'>{H(string.Join(
 3334        sb.Append("</div></details></div>");
 3335        return sb.ToString();
 336    }
 337
 338    private static string BuildPostedAlertSummary(
 339        List<Order> orders,
 340        List<OneCResponse> successful,
 341        string? warehouseSender,
 342        string? warehouseRecipient)
 343    {
 14344        var organization = orders.Select(o => o.OzonClient?.Name?.Trim()).FirstOrDefault(x => !string.IsNullOrWhiteSpace
 22345        var inn = orders.Select(o => o.OzonClient?.INNCode?.Trim()).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
 8346        var sender = !string.IsNullOrWhiteSpace(warehouseSender)
 8347            ? warehouseSender.Trim()
 10348            : orders.Select(GetEffectiveWarehouse).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "—";
 8349        var recipient = !string.IsNullOrWhiteSpace(warehouseRecipient)
 8350            ? warehouseRecipient.Trim()
 20351            : orders.Select(o => o.OzonClient?.WarehouseName?.Trim()).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x))
 8352        var products = ResolveProducts(orders, successful);
 16353        var firstDocument = successful.FirstOrDefault(r => !string.IsNullOrWhiteSpace(r.DocumentNumber) || !string.IsNul
 8354        var documentLabel = firstDocument?.DocumentNumber ?? "Документ 1С";
 8355        var documentLink = firstDocument?.TransactionId is { } transactionId && Guid.TryParse(transactionId, out _)
 8356            ? $"<a class='onec-draft-document-link' href='/Studio2/OneCDocument/Details?refKey={H(transactionId)}' targe
 8357            : H(documentLabel);
 24358        var guids = successful.Select(s => s.TransactionId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().Take(5)
 359
 8360        var sb = new StringBuilder();
 8361        sb.Append("<div class='alert alert-success mb-2 onec-posted-summary'>");
 8362        sb.Append("<div class='d-flex align-items-start justify-content-between gap-2'><strong>Документ 1С создан и пров
 8363        sb.Append($"<div class='onec-draft-document mt-2'>{documentLink}</div>");
 8364        sb.Append($"<div class='small text-secondary'>{H(organization)} · {H(sender)} → {H(recipient)}</div>");
 8365        sb.Append($"<div class='small mt-2'>{BuildCountsLine(orders.Count, products)}</div>");
 8366        sb.Append("<details class='mt-2'><summary class='small fw-semibold'>Подробнее</summary><div class='small mt-2'>"
 8367        sb.Append($"<div><span class='text-muted'>Клиент:</span> {H(organization)}{(string.IsNullOrWhiteSpace(inn) ? str
 8368        sb.Append($"<div><span class='text-muted'>Маршрут:</span> {H(sender)} → {H(recipient)}</div>");
 8369        if (products.Any())
 3370            sb.Append($"<div class='mt-2'><span class='text-muted'>Товары:</span> {string.Join(" · ", products.Select(Fo
 10371        if (guids.Any()) sb.Append($"<div class='mt-2 text-muted'>ID 1С: <span class='font-monospace user-select-all'>{H
 8372        sb.Append("</div></details></div>");
 8373        return sb.ToString();
 374    }
 375
 97376    private static string H(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
 377
 378    /// <summary>
 379    /// Полная детализация для комментария транзакции: все товары, склады, количества, номера документов.
 380    /// </summary>
 381    public static string BuildCommentDetails(
 382        List<Order> ordersToTransfer,
 383        List<OneCResponse> transferResult,
 384        string? warehouseSender = null,
 385        string? warehouseRecipient = null)
 386    {
 9387        transferResult ??= new List<OneCResponse>();
 19388        var successful = transferResult.Where(r => r.Success).ToList();
 19389        var failed = transferResult.Where(r => !r.Success).ToList();
 390
 9391        var sb = new StringBuilder();
 392
 9393        var requestSummary = BuildRequestSummary(ordersToTransfer, warehouseSender, warehouseRecipient);
 9394        if (!string.IsNullOrWhiteSpace(requestSummary))
 395        {
 1396            sb.Append(requestSummary.Trim().TrimEnd('.')).Append(". ");
 397        }
 398
 399        // Номера документов
 9400        var txIds = successful
 9401            .Select(s => !string.IsNullOrWhiteSpace(s.DocumentNumber) ? s.DocumentNumber : s.TransactionId)
 9402            .Where(id => !string.IsNullOrWhiteSpace(id))
 9403            .ToList();
 9404        if (txIds.Any())
 405        {
 3406            sb.Append("1С документы: ").Append(string.Join(", ", txIds)).Append(". ");
 407        }
 408
 409        // Количества берём из строк документа 1С — они могут отличаться от Quantity заказа
 410        // (утеря реализатором уводит в транзит весь остаток склада).
 9411        var docQtyByArticle = successful
 9412            .Where(r => r.DocumentLines != null)
 1413            .SelectMany(r => r.DocumentLines!)
 1414            .Where(l => !string.IsNullOrWhiteSpace(l.Article))
 1415            .GroupBy(l => l.Article.Trim(), StringComparer.OrdinalIgnoreCase)
 12416            .ToDictionary(g => g.Key, g => g.Sum(l => l.Quantity), StringComparer.OrdinalIgnoreCase);
 417
 418        // Группировка по складу
 9419        var warehouseGroups = ordersToTransfer
 1420            .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity > 0)
 1421            .GroupBy(o => GetEffectiveWarehouse(o))
 9422            .ToList();
 423
 20424        foreach (var group in warehouseGroups)
 425        {
 1426            sb.Append($"Склад '{group.Key}': ");
 427
 1428            var items = group
 1429                .Select(o => docQtyByArticle.TryGetValue(o.Article!.Trim(), out var docQty)
 1430                    ? $"{o.Article} x{FormatQty(docQty)}"
 1431                    : $"{o.Article} x{o.Quantity}")
 1432                .ToList();
 433
 434            // Если товаров много — показываем первые 10 и счётчик
 1435            if (items.Count <= 10)
 436            {
 1437                sb.Append(string.Join(", ", items));
 438            }
 439            else
 440            {
 0441                sb.Append(string.Join(", ", items.Take(10)));
 0442                sb.Append($" и ещё {items.Count - 10} шт.");
 443            }
 1444            sb.Append(". ");
 445        }
 446
 447        // Черновик: пропущенная номенклатура
 9448        var missingNotes = successful
 9449            .Where(r => !string.IsNullOrWhiteSpace(r.MissingNomenclature))
 1450            .Select(r => r.MissingNomenclature)
 9451            .Distinct()
 9452            .ToList();
 9453        if (missingNotes.Any())
 454        {
 1455            sb.Append("1С черновик — не найдена номенклатура: ");
 1456            sb.Append(string.Join("; ", missingNotes));
 1457            sb.Append(". ");
 458        }
 459
 9460        var createdNotes = successful
 9461            .Select(r => r.CreatedNomenclature)
 9462            .Where(x => !string.IsNullOrWhiteSpace(x))
 9463            .Distinct()
 9464            .ToList();
 9465        if (createdNotes.Any())
 466        {
 0467            sb.Append("В 1С создана номенклатура: ");
 0468            sb.Append(string.Join("; ", createdNotes));
 0469            sb.Append(". ");
 470        }
 471
 472        // Ошибки проведения (IsPosted=false, но документ создан)
 9473        var postingErrorNotes = successful
 9474            .Where(r => !r.IsPosted && !string.IsNullOrWhiteSpace(r.PostingError))
 5475            .Select(r => r.PostingError!)
 9476            .Distinct()
 9477            .ToList();
 9478        if (postingErrorNotes.Any())
 479        {
 3480            sb.Append("1С ошибка проведения: ");
 3481            sb.Append(string.Join("; ", postingErrorNotes.Select(OneCODataErrorFormatter.Format)));
 3482            sb.Append(". ");
 483        }
 484
 485        // Ошибки создания документа (Success=false)
 9486        if (failed.Any())
 487        {
 1488            sb.Append("Ошибки 1С: ");
 2489            var errors = failed.Select(f => f.Message).Distinct().Take(3);
 1490            sb.Append(string.Join("; ", errors));
 1491            if (failed.Count > 3) sb.Append("...");
 1492            sb.Append(". ");
 493        }
 494
 9495        return sb.ToString().TrimEnd();
 496    }
 497
 498    public static string BuildRequestSummary(
 499        List<Order> ordersToTransfer,
 500        string? warehouseSender = null,
 501        string? warehouseRecipient = null)
 502    {
 9503        if (ordersToTransfer == null || !ordersToTransfer.Any())
 8504            return string.Empty;
 505
 1506        var distinctOrganizations = ordersToTransfer
 1507            .Select(o => o.OzonClient?.Name?.Trim())
 1508            .Where(x => !string.IsNullOrEmpty(x))
 1509            .Distinct(StringComparer.OrdinalIgnoreCase)
 1510            .ToList();
 511
 1512        var distinctOrganizationINNs = ordersToTransfer
 1513            .Select(o => o.OzonClient?.INNCode?.Trim())
 1514            .Where(x => !string.IsNullOrEmpty(x))
 1515            .Distinct(StringComparer.OrdinalIgnoreCase)
 1516            .ToList();
 517
 1518        var distinctSenders = ordersToTransfer
 1519            .Select(o => o.Supplier?.Name?.Trim())
 1520            .Where(x => !string.IsNullOrEmpty(x))
 1521            .Distinct(StringComparer.OrdinalIgnoreCase)
 1522            .ToList();
 523
 1524        var distinctSenderINNs = ordersToTransfer
 1525            .Select(o => o.Supplier?.INNCode?.Trim())
 1526            .Where(x => !string.IsNullOrEmpty(x))
 1527            .Distinct(StringComparer.OrdinalIgnoreCase)
 1528            .ToList();
 529
 1530        var senderWarehouses = !string.IsNullOrEmpty(warehouseSender)
 1531            ? new List<string> { warehouseSender.Trim() }
 1532            : ordersToTransfer
 1533                .Select(GetEffectiveWarehouse)
 0534                .Where(x => !string.IsNullOrEmpty(x))
 1535                .Distinct(StringComparer.OrdinalIgnoreCase)
 1536                .ToList();
 537
 1538        var recipientWarehouses = !string.IsNullOrEmpty(warehouseRecipient)
 1539            ? new List<string> { warehouseRecipient.Trim() }
 1540            : ordersToTransfer
 0541                .Select(o => o.OzonClient?.WarehouseName?.Trim())
 0542                .Where(x => !string.IsNullOrEmpty(x))
 0543                .Select(x => x!)
 1544                .Distinct(StringComparer.OrdinalIgnoreCase)
 1545                .ToList();
 546
 1547        var productGroups = ordersToTransfer
 1548            .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity > 0)
 1549            .GroupBy(o => o.Article!.Trim())
 1550            .Select(g => new
 1551            {
 1552                Article = g.Key,
 1553                Quantity = g.Sum(o => o.Quantity ?? 0)
 1554            })
 1555            .OrderByDescending(g => g.Quantity)
 1556            .ToList();
 557
 1558        var result = new StringBuilder();
 559
 1560        if (distinctOrganizations.Any())
 561        {
 1562            result.Append($"Организация: {string.Join(", ", distinctOrganizations)}");
 1563            if (distinctOrganizationINNs.Any())
 0564                result.Append($" (ИНН {string.Join(", ", distinctOrganizationINNs)})");
 1565            result.Append(". ");
 566        }
 567
 1568        if (distinctSenders.Any())
 569        {
 0570            result.Append($"Отправитель: {string.Join(", ", distinctSenders)}");
 0571            if (distinctSenderINNs.Any())
 0572                result.Append($" (ИНН {string.Join(", ", distinctSenderINNs)})");
 0573            result.Append(". ");
 574        }
 575
 1576        if (senderWarehouses.Any())
 577        {
 1578            result.Append($"Склад отправителя: {string.Join(", ", senderWarehouses)}. ");
 579        }
 580
 1581        if (recipientWarehouses.Any())
 582        {
 1583            result.Append($"Склад получателя: {string.Join(", ", recipientWarehouses)}. ");
 584        }
 585
 1586        if (productGroups.Any())
 587        {
 2588            var totalQuantity = productGroups.Sum(x => x.Quantity);
 1589            var articleSummaries = productGroups
 1590                .Take(4)
 1591                .Select(x => $"{x.Article}×{x.Quantity}")
 1592                .ToList();
 593
 1594            result.Append($"Вход: заказов {ordersToTransfer.Count}, товаров {totalQuantity}");
 1595            if (articleSummaries.Any())
 596            {
 1597                result.Append($", артикулы: {string.Join(", ", articleSummaries)}");
 1598                var remaining = productGroups.Count - articleSummaries.Count;
 1599                if (remaining > 0)
 0600                    result.Append($" +{remaining}");
 601            }
 1602            result.Append(".");
 603        }
 604
 1605        return result.ToString().Trim();
 606    }
 607
 608    private static string GetEffectiveWarehouse(Order order)
 609    {
 3610        var zasyl = order.OrderExtensions?
 0611            .FirstOrDefault(e => string.Equals(e.Key, "Засыл", StringComparison.OrdinalIgnoreCase))
 3612            ?.Value?.Trim();
 613
 3614        if (!string.IsNullOrWhiteSpace(zasyl))
 0615            return zasyl;
 616
 3617        return order.ShipmentWarehouse?.Name?.Trim() ?? "—";
 618    }
 619}

Methods/Properties

WrapSection(System.String,System.String)
BuildAlertSummary(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.String,System.String)
ResolveProducts(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>)
Plural(System.Decimal,System.String,System.String,System.String)
FormatQty(System.Decimal)
BuildCountsLine(System.Int32,System.Collections.Generic.List`1<System.ValueTuple`2<System.String,System.Decimal>>)
FormatProduct(System.ValueTuple`2<System.String,System.Decimal>)
BuildDraftAlertSummary(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.String,System.String)
BuildPostedAlertSummary(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.String,System.String)
H(System.String)
BuildCommentDetails(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.Collections.Generic.List`1<Services.ApiServices._1CApi.Models.OneCResponse>,System.String,System.String)
BuildRequestSummary(System.Collections.Generic.List`1<OzonDomains.Models.Order>,System.String,System.String)
GetEffectiveWarehouse(OzonDomains.Models.Order)