< Summary

Information
Class: Services.ApiServices._1CApi.OneCReceiptManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCReceiptManager.cs
Line coverage
2%
Covered lines: 8
Uncovered lines: 284
Coverable lines: 292
Total lines: 475
Line coverage: 2.7%
Branch coverage
5%
Covered branches: 10
Total branches: 182
Branch coverage: 5.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCReceiptManager.cs

#LineLine coverage
 1using Services.SystemMonitoringServices;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains;
 4using OzonDomains.Models;
 5using Services.ApiServices;
 6using Services.ApiServices._1CApi.DTO;
 7using Services.ApiServices._1CApi.Models;
 8using Services.ApiServices._1CApi.Nomenclature;
 9using Services.OneCServices;
 10using Services.ApiServices._1CApi.OData;
 11using Services.ApiServices._1CApi.OData.Models;
 12
 13namespace Services.ApiServices._1CApi;
 14
 15public class OneCReceiptManager : IApiDataManager<OneCReceiptManager>
 16{
 17    private readonly IOneCODataReferenceService _referenceService;
 18    private readonly IOneCODataClient _odataClient;
 19    private readonly IOneCNomenclatureService _nomenclatureService;
 20    private readonly OneCReceiptDuplicateFinder _duplicateFinder;
 21    private readonly ISystemMonitoringSignalWriter? _signals;
 22    private readonly IOneCPostingFailureReporter? _failureReporter;
 23    private readonly ILogger<OneCReceiptManager> _logger;
 24    private const string ZasylKey = "Засыл";
 25
 26    private const string ReceiptEndpoint = "Document_ПриходнаяНакладная";
 27
 28    /// <summary>
 29    /// PurchasePrice — цена за заказанную единицу (для ARTICLE=BRAND[N] — за партию из N шт.), а в 1С количество
 30    /// идёт в штуках (<see cref="Order.TrueQuantity"/>). Цена строки приводится к цене за штуку,
 31    /// сумма равна PurchasePrice × число заказанных единиц.
 32    /// </summary>
 33    public static (decimal Price, decimal Amount) CalculateLinePrice(Order order, decimal qty)
 34    {
 435        var packSize = order.Quantity is > 0 && order.TrueQuantity is > 0
 436            ? (decimal)order.TrueQuantity.Value / order.Quantity.Value
 437            : 1m;
 438        var purchasePrice = order.PurchasePrice ?? 0m;
 439        return packSize > 1m
 440            ? (Math.Round(purchasePrice / packSize, 4), purchasePrice * order.Quantity!.Value)
 441            : (purchasePrice, purchasePrice * qty);
 42    }
 43
 044    public OneCReceiptManager(
 045        IOneCODataReferenceService referenceService,
 046        IOneCODataClient odataClient,
 047        IOneCNomenclatureService nomenclatureService,
 048        ILogger<OneCReceiptManager> logger,
 049        ISystemMonitoringSignalWriter? signals = null,
 050        IOneCPostingFailureReporter? failureReporter = null)
 51    {
 052        _signals = signals;
 053        _failureReporter = failureReporter;
 054        _referenceService = referenceService;
 055        _odataClient = odataClient;
 056        _nomenclatureService = nomenclatureService;
 057        _logger = logger;
 058        _duplicateFinder = new OneCReceiptDuplicateFinder(odataClient, logger);
 059    }
 60
 61    public async Task<List<OneCResponse>> CreateReceipts(
 62        List<Order> ordersToReceipt,
 63        ShippedBySupplierTransactionDto dto,
 64        string? userName = null,
 65        string? orgInnOverride = null)
 66    {
 067        if (ordersToReceipt == null || !ordersToReceipt.Any())
 068            throw new ArgumentException("Список заказов не может быть пустым.");
 69
 070        var distinctEffectiveWarehouses = ordersToReceipt
 071            .Select(GetEffectiveWarehouseName)
 072            .Where(x => !string.IsNullOrWhiteSpace(x))
 073            .Distinct(StringComparer.OrdinalIgnoreCase)
 074            .ToList();
 75
 076        if (distinctEffectiveWarehouses.Count > 1)
 077            throw new ArgumentException($"Заказы имеют различные эффективные склады: {string.Join(", ", distinctEffectiv
 078        if (distinctEffectiveWarehouses.Count == 0)
 079            throw new ArgumentException("Не указан эффективный склад для заказов.");
 80
 081        var distinctSupplierInns = ordersToReceipt
 082            .Where(o => o.Supplier != null && !string.IsNullOrEmpty(o.Supplier.INNCode))
 083            .Select(o => o.Supplier!.INNCode!)
 084            .Distinct(StringComparer.OrdinalIgnoreCase)
 085            .ToList();
 86
 087        if (distinctSupplierInns.Count == 0)
 088            throw new ArgumentException("Не задан ИНН для поставщика.");
 089        if (distinctSupplierInns.Count > 1)
 090            throw new ArgumentException($"Заказы имеют различных поставщиков: {string.Join(", ", distinctSupplierInns)}"
 91
 092        if (string.IsNullOrWhiteSpace(dto.ContractLink))
 093            throw new ArgumentException("Не указана ссылка (GUID) договора.");
 94
 095        var warehouseName = distinctEffectiveWarehouses[0]!;
 096        var supplierInn = distinctSupplierInns[0];
 97
 98        // Резолвинг контрагента (поставщика) по ИНН
 099        var counterpartyGuid = await _referenceService.GetCounterpartyGuidByInnAsync(supplierInn);
 0100        if (counterpartyGuid == null)
 101        {
 0102            await (_signals?.RecordAsync("onec-supplier", "failure", "counterparty-missing",
 0103                $"Поставщик с ИНН {supplierInn} не найден в справочнике контрагентов 1С") ?? Task.CompletedTask);
 104        }
 105
 0106        if (counterpartyGuid == null)
 0107            return new List<OneCResponse> { new OneCResponse { Success = false,
 0108                Message = $"Поставщик с ИНН '{supplierInn}' не найден в справочнике контрагентов 1С. Добавьте ИНН в Cata
 109
 110        // Резолвинг склада-источника (засыл/склад)
 0111        var warehouseGuid = await _referenceService.GetWarehouseGuidByNameAsync(warehouseName);
 0112        if (warehouseGuid == null)
 0113            return new List<OneCResponse> { new OneCResponse { Success = false,
 0114                Message = $"Склад '{warehouseName}' не найден в справочнике складов 1С (Catalog_СтруктурныеЕдиницы)." } 
 115
 116        // Резолвинг договора
 0117        Guid? contractGuid = null;
 0118        if (Guid.TryParse(dto.ContractLink, out var parsedContract))
 0119            contractGuid = parsedContract;
 120
 121        // Резолвинг ставки НДС
 0122        var vatGuid = dto.VatRate.HasValue
 0123            ? await _referenceService.GetVatRateGuidByRateAsync(dto.VatRate.Value)
 0124            : await _referenceService.GetNoVatRateGuidAsync();
 0125        var noVatGuid = await _referenceService.GetNoVatRateGuidAsync();
 126
 0127        var ordersByClient = ordersToReceipt
 0128            .Where(o => o.OzonClient != null)
 0129            .GroupBy(o => o.OzonClient!)
 0130            .ToDictionary(g => g.Key, g => g.ToList());
 131
 0132        if (!ordersByClient.Any())
 0133            throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon.");
 134
 0135        var results = new List<OneCResponse>();
 0136        var tz = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time");
 0137        var docDate = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz)
 0138            .ToString("yyyy-MM-ddTHH:mm:ss");
 0139        var incomingDate = dto.DateVh.ToString("yyyy-MM-ddTHH:mm:ss");
 140
 0141        foreach (var (client, orders) in ordersByClient)
 142        {
 0143            var recipientWarehouseName = ResolveRecipientWarehouseName(dto.ReceiptWarehouseName, client.WarehouseName);
 0144            if (string.IsNullOrEmpty(client.INNCode) || string.IsNullOrEmpty(recipientWarehouseName))
 145            {
 0146                results.Add(new OneCResponse { Success = false,
 0147                    Message = $"Клиент '{client.Name}' не имеет обязательных данных (ИНН или склад). Заполните в /Studio
 0148                continue;
 149            }
 150
 151            // Override → явный выбор пользователя; иначе всегда "0000000000" (Interparts)
 0152            var effectiveOrgInn = !string.IsNullOrWhiteSpace(orgInnOverride) ? orgInnOverride.Trim() : "0000000000";
 0153            var orgGuid = await _referenceService.GetOrganizationGuidByInnAsync(effectiveOrgInn);
 0154            if (orgGuid == null)
 155            {
 0156                results.Add(new OneCResponse { Success = false,
 0157                    Message = $"Организация с ИНН '{effectiveOrgInn}' не найдена в справочнике организаций 1С. Добавьте 
 0158                continue;
 159            }
 160
 161            // Склад-получатель: выбранный в форме, иначе склад клиента из настроек
 0162            var recipientWarehouseGuid = await _referenceService.GetWarehouseGuidByNameAsync(recipientWarehouseName);
 0163            if (recipientWarehouseGuid == null)
 164            {
 0165                results.Add(new OneCResponse { Success = false,
 0166                    Message = string.IsNullOrWhiteSpace(dto.ReceiptWarehouseName)
 0167                        ? $"Склад клиента '{recipientWarehouseName}' не найден в 1С (Catalog_СтруктурныеЕдиницы). Провер
 0168                        : $"Склад прихода '{recipientWarehouseName}' не найден в 1С (Catalog_СтруктурныеЕдиницы). Выбери
 0169                continue;
 170            }
 171
 0172            var missingItems = new List<(string Article, string? ProductName, decimal Qty)>();
 0173            var createdNomenclature = new List<string>();
 174
 175            // Номенклатура для разных позиций разрешается параллельно (GetOrCreateManyAsync) —
 176            // последовательные вызовы GetOrCreateAsync в цикле давали 7-11 HTTP-запросов к 1С
 177            // на КАЖДУЮ новую позицию строго друг за другом.
 0178            var validOrders = orders
 0179                .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity.HasValue && o.Quantity.Value > 0)
 0180                .ToList();
 0181            var countryGuidTasks = validOrders
 0182                .Select(order => dto.OrderCountryMap.TryGetValue(order.Id, out var country)
 0183                    ? _referenceService.GetCountryGuidByNameAsync(country)
 0184                    : Task.FromResult<Guid?>(null))
 0185                .ToArray();
 0186            var gtdGuidTasks = validOrders
 0187                .Select(order => dto.OrderGtdMap.TryGetValue(order.Id, out var gtd)
 0188                    ? ResolveGtdGuidAsync(gtd)
 0189                    : Task.FromResult<Guid?>(null))
 0190                .ToArray();
 0191            var countryGuids = await Task.WhenAll(countryGuidTasks);
 0192            var gtdGuids = await Task.WhenAll(gtdGuidTasks);
 0193            var invalidOriginData = validOrders
 0194                .Select((order, index) => new { order.Article, Country = countryGuids[index], Gtd = gtdGuids[index] })
 0195                .Where(item => item.Country == null || item.Gtd == null)
 0196                .Select(item => item.Article)
 0197                .ToList();
 0198            if (invalidOriginData.Count > 0)
 199            {
 0200                results.Add(new OneCResponse
 0201                {
 0202                    Success = false,
 0203                    Message = $"Не найдена страна происхождения или номер ГТД в 1С для артикулов: {string.Join(", ", inv
 0204                });
 0205                continue;
 206            }
 207
 0208            var resolutions = await _nomenclatureService.ResolveManyAsync(
 0209                validOrders, NomenclatureMode.FindOrCreate, origin: new OneCNomenclatureOrigin(TransactionType.ShippedBy
 0210            var missingReasons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0211            var nomenclatureNotes = new List<string>();
 212
 0213            var lines = new List<ODataReceiptLine>();
 0214            var lineNum = 1;
 0215            for (var i = 0; i < validOrders.Count; i++)
 216            {
 0217                var order = validOrders[i];
 0218                var resolution = resolutions[i];
 0219                if (resolution.Created)
 0220                    createdNomenclature.Add(resolution.DisplayName);
 0221                if (resolution.FoundByVariant && !string.IsNullOrWhiteSpace(resolution.Reason))
 0222                    nomenclatureNotes.Add($"{order.Article}: {resolution.Reason}");
 223                // validOrders уже отфильтрован по Quantity.HasValue выше.
 0224                var qty = (decimal)(order.TrueQuantity ?? order.Quantity!.Value);
 0225                if (!resolution.RefKey.HasValue)
 226                {
 0227                    missingItems.Add((order.Article ?? string.Empty, order.ProductName, qty));
 0228                    NomenclatureResolution.NoteReason(missingReasons, order.Article ?? string.Empty, resolution.Reason);
 0229                    continue;
 230                }
 231
 0232                var (price, amount) = CalculateLinePrice(order, qty);
 0233                var vatAmount = dto.VatRate.HasValue
 0234                    ? amount * (dto.VatRate.Value / 100m)
 0235                    : 0m;
 0236                var effectiveVatGuid = vatGuid ?? noVatGuid ?? Guid.Empty;
 237
 0238                lines.Add(new ODataReceiptLine
 0239                {
 0240                    LineNumber = lineNum++,
 0241                    NomenclatureKey = resolution.RefKey.Value,
 0242                    CountryOfOriginKey = countryGuids[i],
 0243                    GtdNumberKey = gtdGuids[i],
 0244                    WarehouseKey = recipientWarehouseGuid!.Value,
 0245                    Quantity = qty,
 0246                    Price = price,
 0247                    Amount = amount,
 0248                    VatRateKey = effectiveVatGuid,
 0249                    VatAmount = vatAmount,
 0250                    TotalAmount = amount + vatAmount
 0251                });
 252            }
 253
 0254            if (!lines.Any() && !missingItems.Any())
 255            {
 0256                var msg = $"Для клиента '{client.Name}' не найдено товаров с валидными данными.";
 0257                _logger.LogWarning("[OData] {Msg}", msg);
 0258                results.Add(new OneCResponse { Success = false, Message = msg });
 0259                continue;
 260            }
 261
 0262            var transactionTitle = BuildTransactionTitle(TransactionType.ShippedBySupplier, userName);
 0263            var baseComment = string.IsNullOrWhiteSpace(dto.TransactionComment)
 0264                ? transactionTitle
 0265                : $"{transactionTitle}. {dto.TransactionComment.Trim()}";
 266
 0267            var request = new ODataReceiptRequest
 0268            {
 0269                Date = docDate,
 0270                IncomingDocNumber = dto.NumberVh?.Trim() ?? string.Empty,
 0271                IncomingDocDate = incomingDate,
 0272                OrganizationKey = orgGuid!.Value,
 0273                CounterpartyKey = counterpartyGuid!.Value,
 0274                ContractKey = contractGuid,
 0275                WarehouseKey = recipientWarehouseGuid!.Value,
 0276                AmountIncludesVat = false,
 0277                Lines = lines
 0278            };
 279
 280            // Повтор проведения или ретрай после таймаута не должен плодить вторую накладную: если такая уже есть
 281            // в 1С, возвращаем её, а новый документ не создаём.
 0282            var existing = await _duplicateFinder.FindAsync(
 0283                request.IncomingDocNumber, request.CounterpartyKey, request.WarehouseKey, lines);
 0284            if (existing != null)
 285            {
 0286                _logger.LogInformation(
 0287                    "[OData] Приходная с входящим № {IncomingNumber} (контрагент {Counterparty}) уже есть в 1С: {Number}
 0288                    request.IncomingDocNumber, request.CounterpartyKey, existing.Number, existing.RefKey);
 0289                results.Add(BuildExistingReceiptResponse(existing, request, missingItems, createdNomenclature, nomenclat
 0290                continue;
 291            }
 292
 0293            var response = await CreateAndPostReceiptAsync(request, missingItems, baseComment, createdNomenclature, miss
 0294            _failureReporter?.Report(response, "Приход от поставщика: приходная накладная", orders);
 0295            results.Add(response);
 0296        }
 297
 0298        return results;
 0299    }
 300
 301    private async Task<OneCResponse> CreateAndPostReceiptAsync(
 302        ODataReceiptRequest request,
 303        List<(string Article, string? ProductName, decimal Qty)> missingItems,
 304        string baseComment,
 305        List<string> createdNomenclature,
 306        IReadOnlyDictionary<string, string> missingReasons,
 307        List<string> nomenclatureNotes)
 308    {
 309        const string operationLabel = "создании приходной накладной";
 310        try
 311        {
 0312            string? missingNomStr = null;
 0313            if (missingItems.Any())
 314            {
 0315                missingNomStr = string.Join(", ", missingItems.Select(i => FormatMissingItem(i) + NomenclatureResolution
 0316                var allMissing = (request.Lines ?? []).Count == 0;
 0317                var draftReason = allMissing
 0318                    ? $"⚠ Черновик (все позиции без номенклатуры 1С) — {missingNomStr}"
 0319                    : $"⚠ Черновик — не найдена номенклатура: {missingNomStr}";
 0320                request.Comment = $"{baseComment}\n{draftReason}";
 321                // Создание черновика при нехватке номенклатуры — штатный бизнес-сценарий:
 322                // документ сохраняется в 1С как черновик, а пользователю выводится всплывающее
 323                // уведомление (OneCResponse.Message / TempData["TransactionResult"]).
 324                // Уровень Information, чтобы не спамить в errors-*.log и не заводить issues.
 0325                _logger.LogInformation("[OData] Создаём черновик приходной{Suffix}: не найдена номенклатура для {Missing
 0326                    allMissing ? " (все позиции)" : "", missingNomStr);
 327            }
 328            else
 329            {
 0330                request.Comment = baseComment;
 331            }
 332
 0333            var doc = await _odataClient.PostAsync<ODataReceiptRequest, ODataDocumentResponse>(
 0334                ReceiptEndpoint, request);
 335
 0336            bool isPosted = false;
 0337            string? postingError = null;
 0338            string? errorDetail = null;
 0339            string docNumber = doc.Number ?? "";
 340
 0341            if (missingItems.Any())
 342            {
 0343                var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ReceiptEndpoint, doc.RefKey);
 0344                if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0345                    docNumber = fetched.Number;
 0346                _logger.LogInformation("[OData] Приходная {Number} ({Guid}) — черновик, не найдена номенклатура", docNum
 347            }
 348            else
 349            {
 350                try
 351                {
 0352                    await _odataClient.PostDocumentAsync(ReceiptEndpoint, doc.RefKey);
 0353                    isPosted = true;
 0354                    var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ReceiptEndpoint, doc.RefKey);
 0355                    if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0356                        docNumber = fetched.Number;
 0357                }
 358                // Отказ 1С провести документ — ожидаемый бизнес-исход (обычно не хватает
 359                // остатка): документ остаётся черновиком, а готовая причина уходит пользователю
 360                // в алерт транзакции. Это не сбой интеграции, поэтому Information: Warning и Error
 361                // попадают в errors-*.log и заводят автоматический issue на штатную ситуацию.
 0362                catch (OneCDocumentPostingException postingEx)
 363                {
 0364                    postingError = postingEx.Message;
 0365                    errorDetail = OneCResponseErrors.Describe(postingEx);
 0366                    _logger.LogInformation("[OData] 1С не провела приходной {Guid}: {Reason}", doc.RefKey, postingEx.Det
 0367                }
 0368                catch (Exception patchEx)
 369                {
 0370                    postingError = patchEx.Message;
 0371                    errorDetail = OneCResponseErrors.Describe(patchEx);
 0372                    _logger.LogError(patchEx, "[OData] Ошибка проведения приходной {Guid}", doc.RefKey);
 0373                }
 374            }
 375
 376            string message;
 0377            if (missingItems.Any())
 378            {
 0379                var allMissing = (request.Lines ?? []).Count == 0;
 0380                message = allMissing
 0381                    ? $"Приходная {docNumber} создана как черновик — все позиции без номенклатуры 1С: {missingNomStr}"
 0382                    : $"Приходная {docNumber} создана как черновик — не найдена номенклатура: {missingNomStr}";
 383            }
 384            else
 385            {
 0386                message = isPosted
 0387                    ? $"Приходная накладная {docNumber} создана и проведена"
 0388                    : $"Приходная накладная {docNumber} создана, проведение не выполнено: {postingError}";
 389            }
 390
 0391            return new OneCResponse
 0392            {
 0393                Success = true,
 0394                Message = message,
 0395                TransactionId = doc.RefKey.ToString(),
 0396                DocumentNumber = string.IsNullOrWhiteSpace(docNumber) ? null : docNumber,
 0397                IsPosted = isPosted,
 0398                MissingNomenclature = missingNomStr,
 0399                CreatedNomenclature = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)),
 0400                PostingError = postingError,
 0401                ErrorDetail = errorDetail,
 0402                NomenclatureNotes = nomenclatureNotes.Count > 0 ? nomenclatureNotes : null
 0403            };
 404        }
 0405        catch (Exception ex)
 406        {
 0407            var msg = $"Ошибка при {operationLabel}: {ex.Message}";
 0408            _logger.LogError(ex, "[OData] {Msg}", msg);
 0409            return new OneCResponse { Success = false, Message = msg, ErrorDetail = OneCResponseErrors.Describe(ex) };
 410        }
 0411    }
 412
 413    private static OneCResponse BuildExistingReceiptResponse(
 414        ExistingReceipt existing,
 415        ODataReceiptRequest request,
 416        List<(string Article, string? ProductName, decimal Qty)> missingItems,
 417        List<string> createdNomenclature,
 418        List<string> nomenclatureNotes)
 419    {
 0420        var state = existing.Posted ? "проведена" : "не проведена — проведите её в 1С вручную";
 0421        return new OneCResponse
 0422        {
 0423            Success = true,
 0424            Message = $"Приходная накладная {existing.Number} с входящим № {request.IncomingDocNumber} уже есть в 1С ({s
 0425            TransactionId = existing.RefKey.ToString(),
 0426            DocumentNumber = string.IsNullOrWhiteSpace(existing.Number) ? null : existing.Number,
 0427            IsPosted = existing.Posted,
 0428            MissingNomenclature = missingItems.Count > 0 ? string.Join(", ", missingItems.Select(FormatMissingItem)) : n
 0429            CreatedNomenclature = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)),
 0430            NomenclatureNotes = nomenclatureNotes.Count > 0 ? nomenclatureNotes : null
 0431        };
 432    }
 433
 434    /// <summary>Склад прихода: выбранный пользователем важнее склада клиента из настроек.</summary>
 435    public static string? ResolveRecipientWarehouseName(string? chosen, string? clientWarehouse)
 7436        => !string.IsNullOrWhiteSpace(chosen) ? chosen.Trim() : clientWarehouse?.Trim();
 437
 438    /// <summary>Номер по умолчанию без записи в справочнике означает «без ГТД»: пустая ссылка, а не отказ.</summary>
 439    private async Task<Guid?> ResolveGtdGuidAsync(string gtd)
 440    {
 0441        var key = await _referenceService.GetGtdGuidByNumberAsync(gtd);
 0442        return key == null && OneCODataConstants.IsDefaultGtd(gtd) ? Guid.Empty : key;
 0443    }
 444
 445    private static string FormatMissingItem((string Article, string? ProductName, decimal Qty) item)
 0446        => string.IsNullOrWhiteSpace(item.ProductName)
 0447            ? $"{item.Article} ×{item.Qty}"
 0448            : $"{item.ProductName} [{item.Article}] ×{item.Qty}";
 449
 0450    public OneCReceiptManager SetClient(string user, string password) => this;
 451
 0452    public Task<bool> GetTestRequest(string clientId, string apiKey) => Task.FromResult(true);
 453
 454    private static string? GetZasylValue(Order? order)
 0455        => order?.OrderExtensions?
 0456            .FirstOrDefault(x => string.Equals(x.Key, ZasylKey, StringComparison.OrdinalIgnoreCase))
 0457            ?.Value?.Trim();
 458
 459    private static string? GetEffectiveWarehouseName(Order? order)
 460    {
 0461        var zasyl = GetZasylValue(order);
 0462        return !string.IsNullOrWhiteSpace(zasyl) ? zasyl : order?.ShipmentWarehouse?.Name?.Trim();
 463    }
 464
 465    private static string BuildTransactionTitle(TransactionType transactionType, string? userName)
 466    {
 0467        var n = userName?.Trim();
 0468        var userPart = string.IsNullOrWhiteSpace(n) ||
 0469                       n.Equals("system-auto", StringComparison.OrdinalIgnoreCase) ||
 0470                       n.Equals("system", StringComparison.OrdinalIgnoreCase)
 0471            ? "Система"
 0472            : n;
 0473        return $"{transactionType.GetDisplayName()} (пользователь: {userPart})";
 474    }
 475}