< Summary

Information
Class: Services.ApiServices._1CApi.OneCTransferManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCTransferManager.cs
Line coverage
7%
Covered lines: 40
Uncovered lines: 493
Coverable lines: 533
Total lines: 984
Line coverage: 7.5%
Branch coverage
9%
Covered branches: 29
Total branches: 300
Branch coverage: 9.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Services.SystemMonitoringServices;
 2using Microsoft.Extensions.Logging;
 3using Newtonsoft.Json.Linq;
 4using OzonDomains;
 5using OzonDomains.Models;
 6using Services.ApiServices._1CApi.Models;
 7using Services.ApiServices._1CApi.Nomenclature;
 8using Services.OneCServices;
 9using Services.ApiServices._1CApi.OData;
 10using Services.ApiServices._1CApi.OData.Models;
 11using Services.BackgroundJobsServices;
 12using Services.DataServices;
 13
 14namespace Services.ApiServices._1CApi;
 15
 16public class OneCTransferManager : IApiDataManager<OneCTransferManager>
 17{
 18    private readonly IOneCODataReferenceService _referenceService;
 19    private readonly IOneCODataClient _odataClient;
 20    private readonly IOneCNomenclatureService _nomenclatureService;
 21    private readonly WarehouseMappingDataServices _warehouseMappingDataServices;
 22    private readonly OrdersDataServices _orderServices;
 23    private readonly ISystemMonitoringSignalWriter? _signals;
 24    private readonly IOneCPostingFailureReporter? _failureReporter;
 25    private readonly ILogger<OneCTransferManager> _logger;
 26    private const string ZasylKey = "Засыл";
 27    private const string TransferEndpoint = "Document_ПеремещениеЗапасов";
 28    // Реквизит «Комментарий» документа 1С ограничен 1000 символами.
 29    private const int OneCCommentMaxLength = 1000;
 30
 031    public OneCTransferManager(
 032        IOneCODataReferenceService referenceService,
 033        IOneCODataClient odataClient,
 034        IOneCNomenclatureService nomenclatureService,
 035        WarehouseMappingDataServices warehouseMappingDataServices,
 036        OrdersDataServices orderServices,
 037        ILogger<OneCTransferManager> logger,
 038        ISystemMonitoringSignalWriter? signals = null,
 039        IOneCPostingFailureReporter? failureReporter = null)
 40    {
 041        _signals = signals;
 042        _failureReporter = failureReporter;
 043        _referenceService = referenceService;
 044        _odataClient = odataClient;
 045        _nomenclatureService = nomenclatureService;
 046        _warehouseMappingDataServices = warehouseMappingDataServices;
 047        _orderServices = orderServices;
 048        _logger = logger;
 049    }
 50
 051    public OneCTransferManager SetClient(string user, string password) => this;
 52
 53    // ─── Публичные методы (аналог старых) ─────────────────────────────────────
 54
 55    public async Task<List<OneCResponse>> TransferStockByWarehouses(
 56        List<Order> allOrders,
 57        TransactionType transactionType,
 58        Dictionary<int, string>? orderSupplierMap = null,
 59        DateTime? documentDate = null,
 60        string? userName = null,
 61        string? orgInnOverride = null,
 62        bool createAsDraft = false,
 63        string? draftReason = null)
 64    {
 065        if (allOrders == null || !allOrders.Any())
 066            throw new ArgumentException("Список заказов не может быть пустым.");
 67
 068        var filteredZasylMap = orderSupplierMap?
 69            .Where(m => m.Value != null)
 70            .ToDictionary(m => m.Key, m => m.Value);
 71
 072        var warehouseMappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 73
 074        var ordersWithWarehouse = allOrders
 075            .Select(o => new
 076            {
 077                Ord = o,
 078                Warehouse = ResolveWarehouseFor1C(o, filteredZasylMap, warehouseMappings)
 079            })
 080            .ToList();
 81
 082        var missing = ordersWithWarehouse
 83            .Where(x => string.IsNullOrWhiteSpace(x.Warehouse))
 84            .Select(x => x.Ord.ShipmentNumber)
 085            .ToList();
 086        if (missing.Any())
 087            throw new ArgumentException($"Не заполнен склад у заказов: {string.Join(", ", missing)}");
 88
 089        var allResults = new List<OneCResponse>();
 90        foreach (var warehouseGroup in ordersWithWarehouse.GroupBy(x => x.Warehouse!))
 91        {
 92            try
 93            {
 094                var results = await TransferStock(
 95                    warehouseGroup.Select(x => x.Ord).ToList(),
 096                    transactionType,
 097                    warehouseGroup.Key,
 098                    documentDate,
 099                    userName,
 0100                    filteredZasylMap,
 0101                    orgInnOverride, createAsDraft, draftReason);
 0102                allResults.AddRange(results);
 0103            }
 0104            catch (Exception ex)
 105            {
 0106                allResults.Add(new OneCResponse
 0107                {
 0108                    Success = false,
 0109                    Message = $"Ошибка при обработке склада '{warehouseGroup.Key}': {ex.Message}"
 0110                });
 0111            }
 0112        }
 113
 0114        if (!allResults.Any())
 0115            throw new ArgumentException("Не найдено заказов для обработки.");
 0116        return allResults;
 0117    }
 118
 119    public async Task<List<OneCResponse>> TransferStock(
 120        List<Order> ordersToTransfer,
 121        TransactionType transactionType,
 122        string? zasyl = null,
 123        DateTime? documentDate = null,
 124        string? userName = null,
 125        Dictionary<int, string>? orderSupplierMap = null,
 126        string? orgInnOverride = null,
 127        bool createAsDraft = false,
 128        string? draftReason = null)
 129    {
 0130        if (ordersToTransfer == null || !ordersToTransfer.Any())
 0131            throw new ArgumentException("Список заказов не может быть пустым.");
 132
 133        string warehouseSenderName;
 0134        if (transactionType == TransactionType.AcceptedToWarehouse)
 135        {
 0136            warehouseSenderName = "NON";
 137        }
 0138        else if (string.IsNullOrEmpty(zasyl))
 139        {
 0140            var distinct = ordersToTransfer
 0141                .Select(o => GetEffectiveWarehouseName(o, orderSupplierMap))
 142                .Where(x => !string.IsNullOrWhiteSpace(x))
 0143                .Distinct(StringComparer.OrdinalIgnoreCase)
 0144                .ToList();
 145
 0146            if (distinct.Count > 1)
 0147                throw new ArgumentException($"Заказы имеют различные склады: {string.Join(", ", distinct)}");
 0148            if (distinct.Count == 0)
 0149                throw new ArgumentException("Не указан склад для заказов.");
 0150            warehouseSenderName = distinct[0]!;
 151        }
 152        else
 153        {
 0154            warehouseSenderName = zasyl;
 155        }
 156
 0157        var ordersByClient = ordersToTransfer
 158            .Where(o => o.OzonClient != null && o.OzonClientId.HasValue)
 159            .GroupBy(o => o.OzonClientId!.Value)
 160            .ToDictionary(g => g.Key, g => g.ToList());
 161
 0162        if (!ordersByClient.Any())
 0163            throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon.");
 164
 0165        var results = new List<OneCResponse>();
 166
 0167        foreach (var (_, orders) in ordersByClient)
 168        {
 0169            var client = orders.First().OzonClient!;
 0170            if (string.IsNullOrEmpty(client.INNCode) || string.IsNullOrEmpty(client.WarehouseName))
 0171                throw new ArgumentException($"Клиент '{client.Name}' не имеет обязательных данных (ИНН или склад).");
 172
 0173            var comment = BuildComment(transactionType, userName, client.Name,
 174                orders.Select(o => o.ShipmentNumber));
 175
 0176            var result = await TransferByClientAsync(
 0177                client, orders, warehouseSenderName, client.WarehouseName,
 0178                transactionType, documentDate, comment, orgInnOverride: orgInnOverride,
 0179                createAsDraft: createAsDraft, draftReason: draftReason);
 0180            results.Add(result);
 181        }
 182
 0183        return results;
 0184    }
 185
 186    public async Task<List<OneCResponse>> TransferStockByWarehousesWithReversedWarehouses(
 187        List<Order> allOrders,
 188        TransactionType transactionType,
 189        Dictionary<int, string>? orderSupplierMap = null,
 190        DateTime? documentDate = null,
 191        string? userName = null,
 192        string? orgInnOverride = null,
 193        bool createAsDraft = false,
 194        string? draftReason = null)
 195    {
 0196        if (allOrders == null || !allOrders.Any())
 0197            throw new ArgumentException("Список заказов не может быть пустым.");
 198
 0199        var filteredZasylMap = orderSupplierMap?
 200            .Where(m => m.Value != null)
 201            .ToDictionary(m => m.Key, m => m.Value);
 202
 0203        var warehouseMappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 204
 0205        var ordersWithWarehouse = allOrders
 0206            .Select(o => new
 0207            {
 0208                Order = o,
 0209                Warehouse = ResolveWarehouseFor1C(o, filteredZasylMap, warehouseMappings)
 0210            })
 0211            .ToList();
 212
 0213        var missing = ordersWithWarehouse
 214            .Where(x => string.IsNullOrWhiteSpace(x.Warehouse))
 215            .Select(x => x.Order.ShipmentNumber)
 0216            .ToList();
 0217        if (missing.Any())
 0218            throw new ArgumentException($"Не заполнен склад у заказов: {string.Join(", ", missing)}");
 219
 0220        var allResults = new List<OneCResponse>();
 221        foreach (var warehouseGroup in ordersWithWarehouse.GroupBy(x => x.Warehouse!))
 222        {
 223            try
 224            {
 0225                var results = await TransferStockWithReversedWarehouses(
 226                    warehouseGroup.Select(x => x.Order).ToList(),
 0227                    transactionType,
 0228                    warehouseGroup.Key,
 0229                    documentDate,
 0230                    userName,
 0231                    filteredZasylMap,
 0232                    warehouseMappings,
 0233                    orgInnOverride, createAsDraft, draftReason);
 0234                allResults.AddRange(results);
 0235            }
 0236            catch (Exception ex)
 237            {
 0238                allResults.Add(new OneCResponse
 0239                {
 0240                    Success = false,
 0241                    Message = $"Ошибка при обработке склада '{warehouseGroup.Key}': {ex.Message}"
 0242                });
 0243            }
 0244        }
 245
 0246        if (!allResults.Any())
 0247            throw new ArgumentException("Не найдено заказов для обработки.");
 0248        return allResults;
 0249    }
 250
 251    public async Task<List<OneCResponse>> TransferStockWithCustomWarehouses(
 252        List<Order> ordersToTransfer,
 253        string warehouseSender,
 254        string warehouseRecipient,
 255        TransactionType transactionType,
 256        DateTime? documentDate = null,
 257        string? userName = null,
 258        string? orgInnOverride = null,
 259        bool createAsDraft = false,
 260        string? draftReason = null)
 261    {
 0262        if (ordersToTransfer == null || !ordersToTransfer.Any())
 0263            throw new ArgumentException("Список заказов не может быть пустым.");
 0264        if (string.IsNullOrEmpty(warehouseSender))
 0265            throw new ArgumentException("Не указан склад отправителя.");
 0266        if (string.IsNullOrEmpty(warehouseRecipient))
 0267            throw new ArgumentException("Не указан склад получателя.");
 0268        if (warehouseSender == warehouseRecipient)
 0269            throw new ArgumentException("Склад отправителя и получателя не могут совпадать.");
 270
 0271        var ordersByClient = ordersToTransfer
 272            .Where(o => o.OzonClient != null && o.OzonClientId.HasValue)
 273            .GroupBy(o => o.OzonClientId!.Value)
 274            .ToDictionary(g => g.Key, g => g.ToList());
 275
 0276        if (!ordersByClient.Any())
 0277            throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon.");
 278
 0279        var results = new List<OneCResponse>();
 0280        foreach (var (_, orders) in ordersByClient)
 281        {
 0282            var client = orders.First().OzonClient!;
 0283            if (string.IsNullOrEmpty(client.INNCode))
 0284                throw new ArgumentException($"Клиент '{client.Name}' не имеет ИНН.");
 285
 0286            var comment = BuildComment(transactionType, userName, client.Name,
 287                orders.Select(o => o.ShipmentNumber),
 0288                $"Со склада '{warehouseSender}' на склад '{warehouseRecipient}'.");
 289
 0290            var result = await TransferByClientAsync(
 0291                client, orders, warehouseSender, warehouseRecipient,
 0292                transactionType, documentDate, comment, orgInnOverride: orgInnOverride,
 0293                createAsDraft: createAsDraft, draftReason: draftReason);
 0294            results.Add(result);
 295        }
 296
 0297        return results;
 0298    }
 299
 300    public async Task<bool> WarehouseExistsAsync(string warehouseName) =>
 0301        !string.IsNullOrWhiteSpace(warehouseName) &&
 0302        (await _referenceService.GetWarehouseGuidByNameAsync(warehouseName)).HasValue;
 303
 304    public async Task<List<OneCResponse>> TransferStockToTransitWarehouses(
 305        List<Order> allOrders,
 306        IReadOnlyDictionary<int, decimal>? stockQtyOverrides = null,
 307        DateTime? documentDate = null,
 308        string? userName = null,
 309        string? orgInnOverride = null,
 310        bool createAsDraft = false,
 311        string? draftReason = null)
 312    {
 0313        if (allOrders == null || !allOrders.Any())
 0314            throw new ArgumentException("Список заказов не может быть пустым.");
 315
 0316        var warehouseMappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 317
 0318        var ordersWithTransit = allOrders
 0319            .Select(o => new
 0320            {
 0321                Order = o,
 0322                Resolution = ShippedToCommissionerWarehouseResolver.ResolveTransitWarehouse(o, warehouseMappings)
 0323            })
 0324            .ToList();
 325
 0326        var allResults = new List<OneCResponse>();
 0327        foreach (var transitGroup in ordersWithTransit.GroupBy(
 328                     x => (Sender: x.Resolution.SenderWarehouse ?? "", Transit: x.Resolution.TransitWarehouse ?? "")))
 329        {
 0330            var senderName = transitGroup.Key.Sender;
 0331            var transitName = transitGroup.Key.Transit;
 0332            if (string.IsNullOrWhiteSpace(senderName))
 333            {
 0334                allResults.Add(new OneCResponse
 0335                {
 0336                    Success = false,
 0337                    Message = "Не удалось определить склад отправителя для части заказов."
 0338                });
 0339                continue;
 340            }
 0341            if (string.IsNullOrWhiteSpace(transitName))
 342            {
 0343                allResults.Add(new OneCResponse
 0344                {
 0345                    Success = false,
 0346                    Message = $"Для склада '{senderName}' не заполнен транзитный склад 1С в WarehouseMapping."
 0347                });
 0348                continue;
 349            }
 350
 351            var ordersInGroup = transitGroup.Select(x => x.Order).ToList();
 352
 0353            var ordersByClient = ordersInGroup
 354                .Where(o => o.OzonClient != null && o.OzonClientId.HasValue)
 355                .GroupBy(o => o.OzonClientId!.Value)
 356                .ToDictionary(g => g.Key, g => g.ToList());
 357
 0358            if (!ordersByClient.Any())
 359            {
 0360                allResults.Add(new OneCResponse
 0361                {
 0362                    Success = false,
 0363                    Message = $"Нет заказов с клиентом Ozon для склада '{senderName}'."
 0364                });
 0365                continue;
 366            }
 367
 0368            foreach (var (_, clientOrders) in ordersByClient)
 369            {
 0370                var client = clientOrders.First().OzonClient!;
 0371                if (string.IsNullOrEmpty(client.INNCode))
 372                {
 0373                    allResults.Add(new OneCResponse
 0374                    {
 0375                        Success = false,
 0376                        Message = $"Клиент '{client.Name}' не имеет ИНН."
 0377                    });
 0378                    continue;
 379                }
 380
 0381                var comment = BuildComment(
 0382                    TransactionType.LostByCommissioner, userName, client.Name,
 383                    clientOrders.Select(o => o.ShipmentNumber),
 0384                    $"Со склада '{senderName}' на склад '{transitName}'.");
 385                try
 386                {
 0387                    var result = await TransferByClientAsync(
 0388                        client, clientOrders, senderName, transitName,
 0389                        TransactionType.LostByCommissioner, documentDate, comment,
 0390                        stockQtyOverrides, orgInnOverride: orgInnOverride,
 0391                        createAsDraft: createAsDraft, draftReason: draftReason);
 0392                    allResults.Add(result);
 0393                }
 0394                catch (Exception ex)
 395                {
 0396                    allResults.Add(new OneCResponse
 0397                    {
 0398                        Success = false,
 0399                        Message = $"Ошибка для '{senderName}' → '{transitName}': {ex.Message}"
 0400                    });
 0401                }
 402            }
 0403        }
 404
 0405        if (!allResults.Any())
 0406            throw new ArgumentException("Не найдено заказов для обработки.");
 0407        return allResults;
 0408    }
 409
 410    public async Task<bool> TestConnectionAsync()
 411    {
 412        try
 413        {
 0414            await _referenceService.GetWarehousesAsync();
 0415            return true;
 416        }
 0417        catch
 418        {
 0419            return false;
 420        }
 0421    }
 422
 0423    public Task<bool> GetTestRequest(string user, string password) => TestConnectionAsync();
 424
 425    // ─── Внутренняя логика ─────────────────────────────────────────────────────
 426
 427    private async Task<List<OneCResponse>> TransferStockWithReversedWarehouses(
 428        List<Order> ordersToTransfer,
 429        TransactionType transactionType,
 430        string? zasyl,
 431        DateTime? documentDate,
 432        string? userName,
 433        Dictionary<int, string>? orderSupplierMap,
 434        List<WarehouseMapping>? warehouseMappings,
 435        string? orgInnOverride = null,
 436        bool createAsDraft = false,
 437        string? draftReason = null)
 438    {
 0439        if (ordersToTransfer == null || !ordersToTransfer.Any())
 0440            throw new ArgumentException("Список заказов не может быть пустым.");
 441
 442        string warehouseName;
 0443        if (string.IsNullOrEmpty(zasyl))
 444        {
 0445            var distinct = ordersToTransfer
 0446                .Select(o => ResolveWarehouseFor1C(o, orderSupplierMap, warehouseMappings))
 447                .Where(x => !string.IsNullOrWhiteSpace(x))
 0448                .Distinct(StringComparer.OrdinalIgnoreCase)
 0449                .ToList();
 450
 0451            if (distinct.Count > 1)
 0452                throw new ArgumentException($"Заказы имеют различные склады: {string.Join(", ", distinct)}");
 0453            if (distinct.Count == 0)
 0454                throw new ArgumentException("Не указан склад для заказов.");
 0455            warehouseName = distinct[0]!;
 456        }
 457        else
 458        {
 0459            warehouseName = zasyl;
 460        }
 461
 462        var client = ordersToTransfer.Select(o => o.OzonClient).FirstOrDefault(o => o != null)
 0463            ?? throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon.");
 464
 0465        if (string.IsNullOrEmpty(client.INNCode) || string.IsNullOrEmpty(client.WarehouseName))
 0466            throw new ArgumentException($"Клиент '{client.Name}' не имеет обязательных данных.");
 467
 0468        var swapWarehouses = transactionType == TransactionType.ShippedToCommissioner;
 0469        var senderName = swapWarehouses ? warehouseName : client.WarehouseName;
 0470        var recipientName = swapWarehouses ? client.WarehouseName : warehouseName;
 471
 0472        var comment = BuildComment(transactionType, userName, client.Name,
 473            ordersToTransfer.Select(o => o.ShipmentNumber));
 474
 0475        var result = await TransferByClientAsync(
 0476            client, ordersToTransfer, senderName, recipientName,
 0477            transactionType, documentDate, comment, orgInnOverride: orgInnOverride,
 0478            createAsDraft: createAsDraft, draftReason: draftReason);
 0479        return new List<OneCResponse> { result };
 0480    }
 481
 482    private async Task<OneCResponse> TransferByClientAsync(
 483        OzonClient client,
 484        List<Order> orders,
 485        string warehouseSenderName,
 486        string warehouseRecipientName,
 487        TransactionType transactionType,
 488        DateTime? documentDate,
 489        string comment,
 490        IReadOnlyDictionary<int, decimal>? stockQtyOverrides = null,
 491        string? orgInnOverride = null,
 492        bool createAsDraft = false,
 493        string? draftReason = null)
 494    {
 0495        var response = await TransferByClientCoreAsync(client, orders, warehouseSenderName, warehouseRecipientName, tran
 0496        _failureReporter?.Report(response, $"{transactionType.GetDisplayName()}: перемещение {warehouseSenderName} → {wa
 0497        return response;
 0498    }
 499
 500    private async Task<OneCResponse> TransferByClientCoreAsync(
 501        OzonClient client,
 502        List<Order> orders,
 503        string warehouseSenderName,
 504        string warehouseRecipientName,
 505        TransactionType transactionType,
 506        DateTime? documentDate,
 507        string comment,
 508        IReadOnlyDictionary<int, decimal>? stockQtyOverrides = null,
 509        string? orgInnOverride = null,
 510        bool createAsDraft = false,
 511        string? draftReason = null)
 512    {
 513        const string operationLabel = "создании перемещения";
 514
 515        try
 516        {
 517            // Override → явный выбор пользователя; иначе всегда "0000000000" (Interparts)
 0518            var effectiveOrgInn = !string.IsNullOrWhiteSpace(orgInnOverride) ? orgInnOverride.Trim() : "0000000000";
 0519            var orgGuid = await _referenceService.GetOrganizationGuidByInnAsync(effectiveOrgInn)
 0520                ?? throw new InvalidOperationException($"Организация с ИНН '{effectiveOrgInn}' не найдена в 1С.");
 521
 522            // Резолвинг складов
 0523            var senderGuid = await _referenceService.GetWarehouseGuidByNameAsync(warehouseSenderName)
 0524                ?? throw new InvalidOperationException($"Склад-отправитель '{warehouseSenderName}' не найден в 1С.");
 525
 0526            var recipientGuid = await _referenceService.GetWarehouseGuidByNameAsync(warehouseRecipientName)
 0527                ?? throw new InvalidOperationException($"Склад-получатель '{warehouseRecipientName}' не найден в 1С.");
 528
 529            // Для отгрузки реализатору и утраты реализатором карточки номенклатуры запрещено
 530            // создавать автоматически: документ оставляем черновиком, а отсутствующие позиции
 531            // перечисляем в комментарии. Это правило действует и для фоновых TransactionJobs,
 532            // поскольку все они проходят через этот общий метод.
 0533            var allowNomenclatureCreation = AllowsNomenclatureCreation(transactionType);
 0534            var validOrders = orders
 535                .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity.HasValue && o.Quantity.Value > 0)
 0536                .ToList();
 537            // (article, productName, qty) для позиций без номенклатуры в 1С
 0538            var missingItems = new List<(string Article, string? ProductName, decimal Qty)>();
 0539            var createdNomenclature = new List<string>();
 540
 0541            var resolutions = await _nomenclatureService.ResolveManyAsync(
 0542                validOrders,
 0543                allowNomenclatureCreation ? NomenclatureMode.FindOrCreate : NomenclatureMode.FindOnly,
 0544                origin: new OneCNomenclatureOrigin(transactionType));
 0545            var missingReasons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0546            var nomenclatureNotes = new List<string>();
 547
 0548            var lines = new List<ODataTransferLine>();
 549            // Артикул и количество каждой строки — для сводки в UI: количество может отличаться
 550            // от Quantity заказа (stockQtyOverrides), и показывать надо то, что ушло в 1С.
 0551            var documentLines = new List<OneCDocumentLine>();
 0552            var lineNum = 1;
 0553            for (var i = 0; i < validOrders.Count; i++)
 554            {
 0555                var order = validOrders[i];
 0556                var resolution = resolutions[i];
 0557                var nomenclatureKey = resolution.RefKey;
 0558                if (resolution.Created)
 0559                    createdNomenclature.Add(resolution.DisplayName);
 0560                if (resolution.FoundByVariant && !string.IsNullOrWhiteSpace(resolution.Reason))
 0561                    nomenclatureNotes.Add($"{order.Article}: {resolution.Reason}");
 0562                if (!nomenclatureKey.HasValue)
 563                {
 0564                    var missingQty = stockQtyOverrides != null &&
 0565                                     stockQtyOverrides.TryGetValue(order.Id, out var overriddenMissingQty) &&
 0566                                     overriddenMissingQty > 0
 0567                        ? overriddenMissingQty
 0568                        : (decimal)(order.TrueQuantity ?? order.Quantity!.Value);
 0569                    var missingArticle = GetArticleForNomenclatureSearch(order);
 0570                    missingItems.Add((missingArticle, order.ProductName, missingQty));
 0571                    NomenclatureResolution.NoteReason(missingReasons, missingArticle, resolution.Reason);
 0572                    continue;
 573                }
 574
 0575                var effectiveQty = stockQtyOverrides != null &&
 0576                                   stockQtyOverrides.TryGetValue(order.Id, out var overrideQty) &&
 0577                                   overrideQty > 0
 0578                    ? overrideQty
 0579                    // validOrders уже отфильтрован по Quantity.HasValue выше.
 0580                    : (decimal)(order.TrueQuantity ?? order.Quantity!.Value);
 581
 0582                lines.Add(new ODataTransferLine
 0583                {
 0584                    LineNumber = lineNum++,
 0585                    NomenclatureKey = nomenclatureKey.Value,
 0586                    WarehouseSenderKey = senderGuid,
 0587                    Quantity = effectiveQty
 0588                });
 0589                documentLines.Add(new OneCDocumentLine
 0590                {
 0591                    Article = order.Article ?? GetArticleForNomenclatureSearch(order),
 0592                    Quantity = effectiveQty
 0593                });
 594            }
 595
 0596            if (!lines.Any() && !missingItems.Any())
 597            {
 0598                var msg = $"Для клиента '{client.Name}' не найдено товаров с валидными данными.";
 0599                _logger.LogWarning("[OData] {Msg}", msg);
 0600                return new OneCResponse { Success = false, Message = msg };
 601            }
 602
 0603            var fullComment = createAsDraft
 0604                ? BuildDraftComment(comment, draftReason ?? "недостаточно остатка в 1С. Проведите после оприходования то
 0605                : comment;
 0606            string? missingNomStr = null;
 0607            if (missingItems.Any())
 608            {
 0609                missingNomStr = string.Join(", ", missingItems.Select(i => FormatMissingItem(i) + NomenclatureResolution
 0610                var allMissing = !lines.Any();
 0611                var missingNomenclatureDraftReason = allMissing
 0612                    ? $"⚠ Черновик (все позиции без номенклатуры 1С) — {missingNomStr}"
 0613                    : $"⚠ Черновик — не найдена номенклатура: {missingNomStr}";
 0614                fullComment = BuildDraftComment(fullComment, missingNomenclatureDraftReason);
 615                // Создание черновика при нехватке номенклатуры — штатный бизнес-сценарий:
 616                // документ сохраняется в 1С как черновик, а пользователю выводится всплывающее
 617                // уведомление (OneCResponse.Message / TempData["TransactionResult"]).
 618                // Уровень Information, чтобы не спамить в errors-*.log и не заводить issues.
 0619                await (_signals?.RecordAsync(OneCNomenclatureService.NomenclatureSignalSource, "failure", "missing",
 0620                    $"Перемещение: не найдена номенклатура: {missingNomStr}") ?? Task.CompletedTask);
 0621                _logger.LogInformation("[OData] Создаём черновик перемещения{Suffix}: не найдена номенклатура для {Missi
 0622                    allMissing ? " (все позиции)" : "", missingNomStr);
 623            }
 624
 0625            var request = new ODataTransferRequest
 0626            {
 0627                Date = FormatODataDate(documentDate),
 0628                OrganizationKey = orgGuid,
 0629                WarehouseSenderKey = senderGuid,
 0630                WarehouseRecipientKey = recipientGuid,
 0631                Comment = fullComment,
 0632                Lines = lines
 0633            };
 634
 0635            var doc = await _odataClient.PostAsync<ODataTransferRequest, ODataDocumentResponse>(
 0636                TransferEndpoint, request);
 637
 0638            var documentValidationError = await ValidateCreatedTransferDocumentAsync(doc.RefKey, lines);
 0639            if (documentValidationError != null)
 640            {
 641                // Success=true: документ в 1С реально создан (doc.RefKey существует) — если пометить как
 642                // Success=false, вызывающий код (BaseTransactionController и т.п.) отбросит TransactionId
 643                // как у несостоявшейся операции и при повторной попытке создаст ДУБЛИКАТ документа в 1С.
 644                // Как и при обычной ошибке проведения (см. ветку isPosted ниже), непроведённость сообщаем
 645                // через IsPosted=false/PostingError, а не через Success.
 0646                var msg = $"Перемещение {doc.Number ?? doc.RefKey.ToString()} создано, но не проведено: {documentValidat
 0647                _logger.LogError("[OData] {Message}. Документ {RefKey} оставлен черновиком.", msg, doc.RefKey);
 0648                return new OneCResponse
 0649                {
 0650                    Success = true,
 0651                    Message = msg,
 0652                    TransactionId = doc.RefKey.ToString(),
 0653                    DocumentNumber = doc.Number,
 0654                    IsPosted = false,
 0655                    PostingError = documentValidationError,
 0656                    DocumentLines = documentLines
 0657                };
 658            }
 659
 0660            bool isPosted = false;
 0661            string? postingError = null;
 0662            string? errorDetail = null;
 0663            string? shortageReason = null;
 0664            string docNumber = doc.Number ?? "";
 665
 0666            if (missingItems.Any())
 667            {
 0668                var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(TransferEndpoint, doc.RefKey);
 0669                if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0670                    docNumber = fetched.Number;
 0671                _logger.LogInformation("[OData] Перемещение {Number} ({Guid}) — черновик, отсутствует номенклатура", doc
 672            }
 0673            else if (!createAsDraft)
 674            {
 675                try
 676                {
 0677                    await _odataClient.PostDocumentAsync(TransferEndpoint, doc.RefKey);
 0678                    isPosted = true;
 0679                    var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(TransferEndpoint, doc.RefKey)
 0680                    if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0681                        docNumber = fetched.Number;
 0682                }
 683                // Отказ 1С провести документ — ожидаемый бизнес-исход (обычно не хватает
 684                // остатка): документ остаётся черновиком, а готовая причина уходит пользователю
 685                // в алерт транзакции. Это не сбой интеграции, поэтому Information: Warning и Error
 686                // попадают в errors-*.log и заводят автоматический issue на штатную ситуацию.
 0687                catch (OneCDocumentPostingException postingEx)
 688                {
 0689                    postingError = postingEx.Message;
 0690                    errorDetail = OneCResponseErrors.Describe(postingEx);
 0691                    _logger.LogInformation("[OData] 1С не провела перемещения {Guid}: {Reason}", doc.RefKey, postingEx.D
 0692                    if (OneCDocumentPostingMessage.IsUninformative(postingEx.Detail))
 0693                        shortageReason = await DiagnoseShortageAsync(warehouseSenderName, senderGuid, lines, documentLin
 0694                    if (shortageReason != null)
 695                    {
 0696                        postingError = shortageReason;
 0697                        _logger.LogInformation("[OData] Причина отказа перемещения {Guid} по остаткам: {Shortage}", doc.
 698                    }
 699                }
 0700                catch (Exception patchEx)
 701                {
 0702                    postingError = OneCODataErrorFormatter.Format(patchEx.Message);
 0703                    errorDetail = OneCResponseErrors.Describe(patchEx);
 0704                    _logger.LogError(patchEx, "[OData] Ошибка проведения перемещения {Guid}", doc.RefKey);
 0705                }
 706
 707                // 1С в ответе на /Post при нехватке остатка возвращает только общую фразу
 708                // «Не удалось провести "..."!» без детализации причины. Если в этом же документе
 709                // только что создана номенклатура — она гарантированно имеет нулевой остаток на
 710                // складе-отправителе. Если ВСЕ строки документа — новая номенклатура, это точная
 711                // причина отказа; если только часть строк — это лишь вероятная причина (могло не
 712                // хватить остатка и по уже существующей позиции), формулировку смягчаем.
 0713                if (!isPosted && shortageReason == null && createdNomenclature.Any())
 714                {
 0715                    var namesJoined = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase));
 0716                    var causePrefix = lines.Count == createdNomenclature.Count
 0717                        ? "Не хватает остатка"
 0718                        : "Вероятно, не хватает остатка";
 0719                    postingError =
 0720                        $"{causePrefix} на складе «{warehouseSenderName}»: только что созданная номенклатура ({namesJoin
 721                }
 722            }
 723
 724            string message;
 0725            if (missingItems.Any())
 726            {
 0727                var allMissing = !lines.Any();
 0728                message = allMissing
 0729                    ? $"Перемещение {docNumber} создано как черновик — все позиции без номенклатуры 1С: {missingNomStr}"
 0730                    : $"Перемещение {docNumber} создано как черновик — не найдена номенклатура: {missingNomStr}";
 731            }
 732            else
 733            {
 0734                message = createAsDraft
 0735                    ? $"Перемещение {docNumber} создано как черновик: {draftReason ?? "недостаточно остатка в 1С"}"
 0736                    : isPosted
 0737                    ? $"Перемещение {docNumber} создано и проведено"
 0738                    : $"Перемещение {docNumber} создано, проведение не выполнено: {postingError}";
 739            }
 740
 0741            return new OneCResponse
 0742            {
 0743                Success = true,
 0744                Message = message,
 0745                TransactionId = doc.RefKey.ToString(),
 0746                DocumentNumber = string.IsNullOrWhiteSpace(docNumber) ? null : docNumber,
 0747                IsPosted = isPosted,
 0748                IsDraft = createAsDraft,
 0749                MissingNomenclature = missingNomStr,
 0750                CreatedNomenclature = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)),
 0751                PostingError = postingError,
 0752                ErrorDetail = errorDetail,
 0753                NomenclatureNotes = nomenclatureNotes.Count > 0 ? nomenclatureNotes : null,
 0754                DocumentLines = documentLines
 0755            };
 756        }
 0757        catch (Exception ex)
 758        {
 0759            var msg = $"Ошибка при {operationLabel}: {ex.Message}";
 0760            _logger.LogError(ex, "[OData] {Msg}", msg);
 0761            return new OneCResponse { Success = false, Message = msg, ErrorDetail = OneCResponseErrors.Describe(ex) };
 762        }
 0763    }
 764
 765    // ─── Вспомогательные методы ────────────────────────────────────────────────
 766
 767    /// <summary>
 768    /// Сверяет строки перемещения с остатками на складе-отправителе. Любой сбой чтения остатков не должен
 769    /// ломать ответ — тогда возвращаем <c>null</c>, и пользователь получает прежнее сообщение.
 770    /// </summary>
 771    private async Task<string?> DiagnoseShortageAsync(
 772        string senderName, Guid senderGuid, IReadOnlyList<ODataTransferLine> lines, IReadOnlyList<OneCDocumentLine> docu
 773    {
 774        try
 775        {
 0776            var byNomenclature = lines
 0777                .Select((line, i) => (line.NomenclatureKey, Article: i < documentLines.Count ? documentLines[i].Article 
 778                .GroupBy(x => x.NomenclatureKey)
 0779                .Take(30)
 0780                .ToList();
 781            var warehouses = (await _referenceService.GetWarehousesAsync()).ToDictionary(w => w.RefKey, w => w.Descripti
 782
 0783            var result = new List<TransferShortageLine>();
 0784            foreach (var group in byNomenclature)
 785            {
 0786                var balances = await _odataClient.GetListAsync<ODataStockBalance>(
 0787                    OneCRegisterEndpoint.WithCondition(
 0788                        "AccumulationRegister_ЗапасыНаСкладах/Balance",
 0789                        $"Номенклатура_Key eq guid'{group.Key}'"));
 790                var onSender = balances.Where(b => b.WarehouseKey == senderGuid).Sum(b => b.QuantityBalance);
 0791                var elsewhere = balances
 0792                    .Where(b => b.WarehouseKey != senderGuid && b.QuantityBalance > 0)
 0793                    .Select(b => (warehouses.TryGetValue(b.WarehouseKey, out var name) ? name : b.WarehouseKey.ToString(
 0794                    .ToList();
 795                result.Add(new TransferShortageLine(group.First().Article, group.Sum(x => x.Quantity), onSender, elsewhe
 0796            }
 797
 0798            return OneCTransferShortage.Build(senderName, result);
 799        }
 0800        catch (Exception ex)
 801        {
 0802            _logger.LogInformation(ex, "[OData] Не удалось сверить остатки для причины отказа перемещения");
 0803            return null;
 804        }
 0805    }
 806
 807    /// <summary>
 808    /// 1С может принять POST документа, но тихо отбросить строку табличной части.
 809    /// До проведения сверяем фактически созданные строки; одинаковая номенклатура может быть
 810    /// объединена 1С, поэтому сравнение выполняется по суммарному количеству на Ref_Key.
 811    /// </summary>
 812    private async Task<string?> ValidateCreatedTransferDocumentAsync(
 813        Guid documentRefKey,
 814        IReadOnlyCollection<ODataTransferLine> expectedLines)
 815    {
 0816        var rawDocument = await _odataClient.GetRawJsonAsync(TransferEndpoint, documentRefKey);
 0817        return VerifyCreatedTransferDocumentContents(rawDocument, expectedLines);
 0818    }
 819
 820    internal static string? VerifyCreatedTransferDocumentContents(
 821        string? rawDocument,
 822        IReadOnlyCollection<ODataTransferLine> expectedLines)
 823    {
 3824        if (string.IsNullOrWhiteSpace(rawDocument) || rawDocument == "{}")
 1825            return "не удалось повторно прочитать созданный документ из 1С для сверки строк";
 826
 2827        var expected = expectedLines
 4828            .GroupBy(line => line.NomenclatureKey)
 12829            .ToDictionary(group => group.Key, group => group.Sum(line => line.Quantity));
 830
 831        try
 832        {
 2833            var rows = JObject.Parse(rawDocument)["Запасы"] as JArray;
 2834            if (rows == null)
 0835                return "1С вернула документ без табличной части «Запасы»";
 836
 2837            var actual = rows
 2838                .Select(row => new
 2839                {
 2840                    NomenclatureKey = Guid.TryParse(row?["Номенклатура_Key"]?.Value<string>(), out var key)
 2841                        ? key
 2842                        : Guid.Empty,
 2843                    Quantity = row?["Количество"]?.Value<decimal>() ?? 0
 2844                })
 2845                .GroupBy(line => line.NomenclatureKey)
 8846                .ToDictionary(group => group.Key, group => group.Sum(line => line.Quantity));
 847
 2848            if (expected.Count == actual.Count && expected.All(pair =>
 3849                    actual.TryGetValue(pair.Key, out var quantity) && quantity == pair.Value))
 1850                return null;
 851
 2852            static string Format(IReadOnlyDictionary<Guid, decimal> lines) => string.Join(", ",
 3853                lines.OrderBy(pair => pair.Key)
 5854                    .Select(pair => $"{pair.Key}×{pair.Value}"));
 855
 1856            return $"состав строк в 1С не совпадает с отправленным (ожидалось: {Format(expected)}; получено: {Format(act
 857        }
 0858        catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or FormatException or InvalidCastException)
 859        {
 0860            return $"не удалось разобрать табличную часть «Запасы» созданного документа: {ex.Message}";
 861        }
 2862    }
 863
 864    private static string? GetZasylValue(Order? order)
 0865        => order?.OrderExtensions?
 0866            .FirstOrDefault(x => string.Equals(x.Key, ZasylKey, StringComparison.OrdinalIgnoreCase))
 0867            ?.Value?.Trim();
 868
 869    private static string? GetEffectiveWarehouseName(Order? order)
 870    {
 0871        var zasyl = GetZasylValue(order);
 0872        return !string.IsNullOrWhiteSpace(zasyl) ? zasyl : order?.ShipmentWarehouse?.Name?.Trim();
 873    }
 874
 875    private static string? GetEffectiveWarehouseName(Order? order, Dictionary<int, string>? orderSupplierMap)
 876    {
 0877        if (orderSupplierMap != null && order != null &&
 0878            orderSupplierMap.TryGetValue(order.Id, out var zasylFromMap) &&
 0879            !string.IsNullOrWhiteSpace(zasylFromMap))
 0880            return zasylFromMap.Trim();
 0881        return GetEffectiveWarehouseName(order);
 882    }
 883
 884    /// <summary>
 885    /// Склад-отправитель для документа 1С. Публичный, потому что предварительная проверка остатков
 886    /// обязана считать склад ровно так же, как создание документа, — иначе проверяется не тот склад.
 887    /// </summary>
 888    public static string? ResolveWarehouseFor1C(
 889        Order order,
 890        Dictionary<int, string>? orderSupplierMap,
 891        List<WarehouseMapping>? warehouseMappings)
 892    {
 6893        if (orderSupplierMap != null &&
 6894            orderSupplierMap.TryGetValue(order.Id, out var zasylFromMap) &&
 6895            !string.IsNullOrWhiteSpace(zasylFromMap))
 1896            return zasylFromMap.Trim();
 897
 5898        if (warehouseMappings != null)
 5899            return ShippedToCommissionerWarehouseResolver.ResolveSenderWarehouse(order, warehouseMappings);
 900
 0901        return GetEffectiveWarehouseName(order);
 902    }
 903
 904    private static string FormatODataDate(DateTime? documentDate)
 905    {
 0906        var tz = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time");
 0907        var effectiveDate = documentDate.HasValue
 0908            ? NormalizeToRussianTime(documentDate.Value, tz)
 0909            : TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
 0910        return effectiveDate.ToString("yyyy-MM-ddTHH:mm:ss");
 911    }
 912
 913    private static DateTime NormalizeToRussianTime(DateTime value, TimeZoneInfo tz)
 0914        => value.Kind switch
 0915        {
 0916            DateTimeKind.Utc => TimeZoneInfo.ConvertTimeFromUtc(value, tz),
 0917            DateTimeKind.Local => TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Local, tz),
 0918            _ => value
 0919        };
 920
 921    private static string BuildTransactionTitle(TransactionType transactionType, string? userName)
 922    {
 0923        var userPart = NormalizeUserName(userName);
 0924        return $"{transactionType.GetDisplayName()} (пользователь: {userPart})";
 925    }
 926
 927    private static string NormalizeUserName(string? userName)
 928    {
 0929        if (string.IsNullOrWhiteSpace(userName)) return "Система";
 0930        var n = userName.Trim();
 0931        return n.Equals("system-auto", StringComparison.OrdinalIgnoreCase) ||
 0932               n.Equals("system", StringComparison.OrdinalIgnoreCase)
 0933            ? "Система"
 0934            : n;
 935    }
 936
 937    private static string FormatMissingItem((string Article, string? ProductName, decimal Qty) item)
 0938        => string.IsNullOrWhiteSpace(item.ProductName)
 0939            ? $"{item.Article} ×{item.Qty}"
 0940            : $"{item.ProductName} [{item.Article}] ×{item.Qty}";
 941
 942    internal static bool AllowsNomenclatureCreation(TransactionType transactionType) =>
 3943        transactionType is not TransactionType.OrderedToSeller
 3944            and not TransactionType.ShippedToSeller
 3945            and not TransactionType.LostByCommissioner;
 946
 947    /// <summary>
 948    /// Очищенный артикул из внешней системы может не совпадать с написанием в 1С. Если ProductKey
 949    /// сохраняет исходную часть до «=» со спецсимволом (PR-66622), ищем по ней.
 950    /// </summary>
 951    private static string GetArticleForNomenclatureSearch(Order order)
 952    {
 0953        var productKeyArticle = order.ProductKey?.Split('=', 2)[0]?.Trim();
 0954        if (!string.IsNullOrWhiteSpace(productKeyArticle) &&
 0955            productKeyArticle.Any(ch => !char.IsLetterOrDigit(ch)))
 0956            return productKeyArticle;
 957
 0958        return order.Article?.Trim() ?? string.Empty;
 959    }
 960
 961    internal static string BuildDraftComment(string comment, string draftReason)
 962    {
 1963        var suffix = draftReason.Length <= OneCCommentMaxLength
 1964            ? draftReason
 1965            : draftReason[..Math.Max(0, OneCCommentMaxLength - "… (перечень обрезан)".Length)] + "… (перечень обрезан)";
 1966        var baseLimit = Math.Max(0, OneCCommentMaxLength - suffix.Length - 1);
 1967        var baseComment = comment.Length <= baseLimit ? comment : comment[..baseLimit];
 1968        return string.IsNullOrEmpty(baseComment) ? suffix : $"{baseComment}\n{suffix}";
 969    }
 970
 971    private static string BuildComment(
 972        TransactionType transactionType,
 973        string? userName,
 974        string clientName,
 975        IEnumerable<string?> shipmentNumbers,
 976        string? extra = null)
 977    {
 0978        var title = BuildTransactionTitle(transactionType, userName);
 0979        var numbers = string.Join(", ", shipmentNumbers.Where(n => !string.IsNullOrEmpty(n)));
 0980        var comment = $"{title} для клиента {clientName}. Заказы: {numbers}";
 0981        if (!string.IsNullOrEmpty(extra)) comment += $" {extra}";
 0982        return comment;
 983    }
 984}

Methods/Properties

.ctor(Services.ApiServices._1CApi.OData.IOneCODataReferenceService,Services.ApiServices._1CApi.OData.IOneCODataClient,Services.ApiServices._1CApi.OData.IOneCNomenclatureService,Services.DataServices.WarehouseMappingDataServices,Services.DataServices.OrdersDataServices,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.OneCTransferManager>,Services.SystemMonitoringServices.ISystemMonitoringSignalWriter,Services.OneCServices.IOneCPostingFailureReporter)
SetClient(System.String,System.String)
TransferStockByWarehouses()
TransferStock()
TransferStockByWarehousesWithReversedWarehouses()
TransferStockWithCustomWarehouses()
WarehouseExistsAsync()
TransferStockToTransitWarehouses()
TestConnectionAsync()
GetTestRequest(System.String,System.String)
TransferStockWithReversedWarehouses()
TransferByClientAsync()
TransferByClientCoreAsync()
DiagnoseShortageAsync()
ValidateCreatedTransferDocumentAsync()
VerifyCreatedTransferDocumentContents(System.String,System.Collections.Generic.IReadOnlyCollection`1<Services.ApiServices._1CApi.OData.Models.ODataTransferLine>)
Format()
GetZasylValue(OzonDomains.Models.Order)
GetEffectiveWarehouseName(OzonDomains.Models.Order)
GetEffectiveWarehouseName(OzonDomains.Models.Order,System.Collections.Generic.Dictionary`2<System.Int32,System.String>)
ResolveWarehouseFor1C(OzonDomains.Models.Order,System.Collections.Generic.Dictionary`2<System.Int32,System.String>,System.Collections.Generic.List`1<OzonDomains.Models.WarehouseMapping>)
FormatODataDate(System.Nullable`1<System.DateTime>)
NormalizeToRussianTime(System.DateTime,System.TimeZoneInfo)
BuildTransactionTitle(OzonDomains.TransactionType,System.String)
NormalizeUserName(System.String)
FormatMissingItem(System.ValueTuple`3<System.String,System.String,System.Decimal>)
AllowsNomenclatureCreation(OzonDomains.TransactionType)
GetArticleForNomenclatureSearch(OzonDomains.Models.Order)
BuildDraftComment(System.String,System.String)
BuildComment(OzonDomains.TransactionType,System.String,System.String,System.Collections.Generic.IEnumerable`1<System.String>,System.String)