< Summary

Information
Class: Services.ApiServices._1CApi.OneCExpenseManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OneCExpenseManager.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 286
Coverable lines: 286
Total lines: 496
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 152
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1using Services.SystemMonitoringServices;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains;
 4using OzonDomains.Models;
 5using Services.ApiServices._1CApi.Models;
 6using Services.ApiServices._1CApi.Nomenclature;
 7using Services.OneCServices;
 8using Services.ApiServices._1CApi.OData;
 9using Services.ApiServices._1CApi.OData.Models;
 10using Services.BackgroundJobsServices;
 11using Services.DataServices;
 12
 13namespace Services.ApiServices._1CApi;
 14
 15public class OneCExpenseManager : IApiDataManager<OneCExpenseManager>
 16{
 17    private readonly IOneCODataReferenceService _referenceService;
 18    private readonly IOneCODataClient _odataClient;
 19    private readonly IOneCNomenclatureService _nomenclatureService;
 20    private readonly WarehouseMappingDataServices _warehouseMappingDataServices;
 21    private readonly ISystemMonitoringSignalWriter? _signals;
 22    private readonly IOneCPostingFailureReporter? _failureReporter;
 23    private readonly ILogger<OneCExpenseManager> _logger;
 24    private const string ZasylKey = "Засыл";
 25    private const string ExpenseEndpoint = "Document_РасходнаяНакладная";
 26
 027    public OneCExpenseManager(
 028        IOneCODataReferenceService referenceService,
 029        IOneCODataClient odataClient,
 030        IOneCNomenclatureService nomenclatureService,
 031        WarehouseMappingDataServices warehouseMappingDataServices,
 032        ILogger<OneCExpenseManager> logger,
 033        ISystemMonitoringSignalWriter? signals = null,
 034        IOneCPostingFailureReporter? failureReporter = null)
 35    {
 036        _signals = signals;
 037        _failureReporter = failureReporter;
 038        _referenceService = referenceService;
 039        _odataClient = odataClient;
 040        _nomenclatureService = nomenclatureService;
 041        _warehouseMappingDataServices = warehouseMappingDataServices;
 042        _logger = logger;
 043    }
 44
 045    public OneCExpenseManager SetClient(string user, string password) => this;
 46
 47    public async Task<List<OneCResponse>> SaleStockByWarehouses(
 48        List<Order> allOrders,
 49        TransactionType transactionType,
 50        string? contractLink = null,
 51        string? transactionComment = null,
 52        int? vatRate = null,
 53        DateTime? documentDate = null,
 54        string? userName = null,
 55        string? orgInnOverride = null,
 56        bool createAsDraft = false,
 57        string? draftReason = null)
 58    {
 059        if (allOrders == null || !allOrders.Any())
 060            throw new ArgumentException("Список заказов не может быть пустым.");
 61
 062        var warehouseMappings = await _warehouseMappingDataServices.GetWarehouseMappings();
 063        var ordersWithWarehouse = allOrders
 064            .Select(o => new
 065            {
 066                Order = o,
 067                Warehouse = ShippedToCommissionerWarehouseResolver.ResolveSenderWarehouse(o, warehouseMappings)
 068            })
 069            .ToList();
 70
 071        var missing = ordersWithWarehouse
 072            .Where(x => string.IsNullOrWhiteSpace(x.Warehouse))
 073            .Select(x => x.Order.ShipmentNumber)
 074            .ToList();
 075        if (missing.Any())
 076            throw new ArgumentException($"Для отправки в 1С у заказов не заполнен склад (засыл/склад): {string.Join(", "
 77
 078        var allResults = new List<OneCResponse>();
 079        foreach (var warehouseGroup in ordersWithWarehouse.GroupBy(x => x.Warehouse!))
 80        {
 81            try
 82            {
 083                var results = await SaleStock(
 084                    warehouseGroup.Select(x => x.Order).ToList(),
 085                    transactionType,
 086                    warehouseGroup.Key,
 087                    contractLink,
 088                    transactionComment,
 089                    vatRate,
 090                    documentDate,
 091                    userName,
 092                    orgInnOverride,
 093                    createAsDraft,
 094                    draftReason);
 095                allResults.AddRange(results);
 096            }
 097            catch (Exception ex)
 98            {
 099                allResults.Add(new OneCResponse
 0100                {
 0101                    Success = false,
 0102                    Message = $"Ошибка при обработке склада '{warehouseGroup.Key}': {ex.Message}"
 0103                });
 0104            }
 0105        }
 106
 0107        return allResults;
 0108    }
 109
 110    public async Task<List<OneCResponse>> SaleStock(
 111        List<Order> ordersToSale,
 112        TransactionType transactionType,
 113        string? zasyl = null,
 114        string? contractLink = null,
 115        string? transactionComment = null,
 116        int? vatRate = null,
 117        DateTime? documentDate = null,
 118        string? userName = null,
 119        string? orgInnOverride = null,
 120        bool createAsDraft = false,
 121        string? draftReason = null)
 122    {
 0123        if (ordersToSale == null || !ordersToSale.Any())
 0124            throw new ArgumentException("Список заказов не может быть пустым.");
 125
 126        string warehouseName;
 0127        if (string.IsNullOrEmpty(zasyl))
 128        {
 0129            var distinct = ordersToSale
 0130                .Select(GetEffectiveWarehouseName)
 0131                .Where(x => !string.IsNullOrWhiteSpace(x))
 0132                .Distinct(StringComparer.OrdinalIgnoreCase)
 0133                .ToList();
 134
 0135            if (distinct.Count > 1)
 0136                throw new ArgumentException($"Заказы имеют различные эффективные склады: {string.Join(", ", distinct)}")
 0137            if (distinct.Count == 0)
 0138                throw new ArgumentException("Не указан эффективный склад для заказов.");
 0139            warehouseName = distinct[0]!;
 140        }
 141        else
 142        {
 0143            warehouseName = zasyl;
 144        }
 145
 0146        var ordersByClient = ordersToSale
 0147            .Where(o => o.OzonClient != null && o.OzonClientId.HasValue)
 0148            .GroupBy(o => o.OzonClientId!.Value)
 0149            .ToDictionary(g => g.Key, g => g.ToList());
 150
 0151        if (!ordersByClient.Any())
 0152            throw new ArgumentException("Не найдено заказов с валидными данными клиента Ozon.");
 153
 0154        var results = new List<OneCResponse>();
 0155        foreach (var (_, orders) in ordersByClient)
 156        {
 0157            var client = orders.First().OzonClient!;
 0158            if (string.IsNullOrEmpty(client.INNCode) || string.IsNullOrEmpty(client.WarehouseName))
 0159                throw new ArgumentException($"Клиент '{client.Name}' не имеет обязательных данных (ИНН или склад).");
 160
 0161            var comment = BuildComment(transactionType, userName, client.Name,
 0162                orders.Select(o => o.ShipmentNumber), transactionComment);
 163
 0164            var result = await CreateExpenseByClientAsync(
 0165                client, orders, warehouseName, transactionType,
 0166                contractLink, comment, vatRate, documentDate, orgInnOverride, createAsDraft, draftReason);
 0167            results.Add(result);
 168        }
 169
 0170        return results;
 0171    }
 172
 173    private async Task<OneCResponse> CreateExpenseByClientAsync(
 174        OzonClient client,
 175        List<Order> orders,
 176        string warehouseName,
 177        TransactionType transactionType,
 178        string? contractLink,
 179        string comment,
 180        int? vatRate,
 181        DateTime? documentDate,
 182        string? orgInnOverride = null,
 183        bool createAsDraft = false,
 184        string? draftReason = null)
 185    {
 0186        var response = await CreateExpenseByClientCoreAsync(client, orders, warehouseName, transactionType, contractLink
 0187        _failureReporter?.Report(response, $"{transactionType.GetDisplayName()}: расходная накладная, склад {warehouseNa
 0188        return response;
 0189    }
 190
 191    private async Task<OneCResponse> CreateExpenseByClientCoreAsync(
 192        OzonClient client,
 193        List<Order> orders,
 194        string warehouseName,
 195        TransactionType transactionType,
 196        string? contractLink,
 197        string comment,
 198        int? vatRate,
 199        DateTime? documentDate,
 200        string? orgInnOverride = null,
 201        bool createAsDraft = false,
 202        string? draftReason = null)
 203    {
 204        const string operationLabel = "создании расходной накладной";
 205        try
 206        {
 0207            var effectiveOrgInn = !string.IsNullOrWhiteSpace(orgInnOverride) ? orgInnOverride.Trim() : "0000000000";
 0208            var orgGuid = await _referenceService.GetOrganizationGuidByInnAsync(effectiveOrgInn);
 0209            if (orgGuid == null)
 0210                return new OneCResponse
 0211                {
 0212                    Success = false,
 0213                    Message = $"Организация с ИНН '{effectiveOrgInn}' не найдена в справочнике организаций 1С. Проверьте
 0214                };
 215
 0216            var counterpartyGuid = await _referenceService.GetCounterpartyGuidByInnAsync(client.INNCode ?? "");
 0217            if (counterpartyGuid == null)
 0218                return new OneCResponse
 0219                {
 0220                    Success = false,
 0221                    Message = $"Покупатель с ИНН '{client.INNCode}' не найден в справочнике контрагентов 1С."
 0222                };
 223
 0224            var warehouseGuid = await _referenceService.GetWarehouseGuidByNameAsync(warehouseName)
 0225                ?? throw new InvalidOperationException($"Склад '{warehouseName}' не найден в 1С.");
 226
 0227            var effectiveVatGuid = vatRate.HasValue
 0228                ? await _referenceService.GetVatRateGuidByRateAsync(vatRate.Value)
 0229                    ?? await _referenceService.GetNoVatRateGuidAsync()
 0230                    ?? Guid.Empty
 0231                : await _referenceService.GetNoVatRateGuidAsync()
 0232                    ?? Guid.Empty;
 233
 0234            var missingItems = new List<(string Article, string? ProductName, decimal Qty)>();
 0235            var createdNomenclature = new List<string>();
 0236            var totalAmount = 0m;
 237
 238            // Номенклатура для разных позиций разрешается параллельно (GetOrCreateManyAsync) —
 239            // последовательные вызовы GetOrCreateAsync в цикле давали 7-11 HTTP-запросов к 1С
 240            // на КАЖДУЮ новую позицию строго друг за другом.
 0241            var validOrders = orders
 0242                .Where(o => !string.IsNullOrEmpty(o.Article) && o.Quantity.HasValue && o.Quantity.Value > 0)
 0243                .ToList();
 0244            var resolutions = await _nomenclatureService.ResolveManyAsync(
 0245                validOrders, NomenclatureMode.FindOrCreate, origin: new OneCNomenclatureOrigin(transactionType));
 0246            var missingReasons = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 0247            var nomenclatureNotes = new List<string>();
 248
 0249            var lines = new List<ODataExpenseLine>();
 0250            var lineNum = 1;
 0251            for (var i = 0; i < validOrders.Count; i++)
 252            {
 0253                var order = validOrders[i];
 0254                var resolution = resolutions[i];
 0255                if (resolution.Created)
 0256                    createdNomenclature.Add(resolution.DisplayName);
 0257                if (resolution.FoundByVariant && !string.IsNullOrWhiteSpace(resolution.Reason))
 0258                    nomenclatureNotes.Add($"{order.Article}: {resolution.Reason}");
 259
 260                // validOrders уже отфильтрован по Quantity.HasValue выше.
 0261                var qty = (decimal)(order.TrueQuantity ?? order.Quantity!.Value);
 0262                if (!resolution.RefKey.HasValue)
 263                {
 0264                    missingItems.Add((order.Article ?? string.Empty, order.ProductName, qty));
 0265                    NomenclatureResolution.NoteReason(missingReasons, order.Article ?? string.Empty, resolution.Reason);
 0266                    continue;
 267                }
 0268                var price = order.Price ?? 0m;
 0269                var amount = price * qty;
 0270                var vatAmount = vatRate.HasValue && vatRate.Value > 0
 0271                    ? Math.Round(amount * vatRate.Value / (100m + vatRate.Value), 2)
 0272                    : 0m;
 273
 0274                totalAmount += amount;
 0275                lines.Add(new ODataExpenseLine
 0276                {
 0277                    LineNumber = lineNum++,
 0278                    NomenclatureKey = resolution.RefKey.Value,
 0279                    WarehouseKey = warehouseGuid,
 0280                    Quantity = qty,
 0281                    Price = price,
 0282                    Amount = amount,
 0283                    VatAmount = vatAmount,
 0284                    TotalAmount = amount,
 0285                    VatRateKey = effectiveVatGuid
 0286                });
 287            }
 288
 0289            if (!lines.Any() && !missingItems.Any())
 0290                return new OneCResponse { Success = false, Message = $"Клиент '{client.Name}': нет позиций с валидными д
 291
 0292            string fullComment = comment;
 0293            string? missingNomStr = null;
 0294            if (createAsDraft)
 295            {
 0296                fullComment = $"{comment}\n⚠ Черновик: {draftReason ?? "недостаточно остатка в 1С. Проведите после оприх
 297            }
 0298            if (missingItems.Any())
 299            {
 0300                missingNomStr = string.Join(", ", missingItems.Select(i => FormatMissingItem(i) + NomenclatureResolution
 0301                var allMissing = !lines.Any();
 0302                var missingNomenclatureDraftReason = allMissing
 0303                    ? $"⚠ Черновик (все позиции без номенклатуры 1С) — {missingNomStr}"
 0304                    : $"⚠ Черновик — не найдена номенклатура: {missingNomStr}";
 0305                fullComment = $"{fullComment}\n{missingNomenclatureDraftReason}";
 306                // Создание черновика при нехватке номенклатуры — штатный бизнес-сценарий:
 307                // документ сохраняется в 1С как черновик, а пользователю выводится всплывающее
 308                // уведомление (OneCResponse.Message / TempData["TransactionResult"]).
 309                // Уровень Information, чтобы не спамить в errors-*.log и не заводить issues.
 0310                await (_signals?.RecordAsync(OneCNomenclatureService.NomenclatureSignalSource, "failure", "missing",
 0311                    $"Расходная: не найдена номенклатура: {missingNomStr}") ?? Task.CompletedTask);
 0312                _logger.LogInformation("[OData] Создаём черновик расходной накладной{Suffix}: не найдена номенклатура дл
 0313                    !lines.Any() ? " (все позиции)" : "", missingNomStr);
 314            }
 315
 0316            Guid? contractGuid = Guid.TryParse(contractLink, out var parsedGuid) ? parsedGuid : null;
 317
 0318            var request = new ODataExpenseRequest
 0319            {
 0320                Date = FormatODataDate(documentDate),
 0321                OrganizationKey = orgGuid.Value,
 0322                CounterpartyKey = counterpartyGuid.Value,
 0323                ContractKey = contractGuid,
 0324                WarehouseKey = warehouseGuid,
 0325                DivisionKey = warehouseGuid,
 0326                TotalAmount = totalAmount,
 0327                Comment = fullComment,
 0328                Lines = lines
 0329            };
 330
 0331            var doc = await _odataClient.PostAsync<ODataExpenseRequest, ODataDocumentResponse>(
 0332                ExpenseEndpoint, request);
 333
 0334            bool isPosted = false;
 0335            string? postingError = null;
 0336            string? errorDetail = null;
 0337            string docNumber = doc.Number ?? "";
 338
 0339            if (missingItems.Any())
 340            {
 0341                var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ExpenseEndpoint, doc.RefKey);
 0342                if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0343                    docNumber = fetched.Number;
 0344                _logger.LogInformation("[OData] Расходная {Number} ({Guid}) — черновик, отсутствует номенклатура", docNu
 345            }
 0346            else if (!createAsDraft)
 347            {
 348                try
 349                {
 0350                    await _odataClient.PostDocumentAsync(ExpenseEndpoint, doc.RefKey);
 0351                    isPosted = true;
 0352                    var fetched = await _odataClient.GetByGuidAsync<ODataDocumentResponse>(ExpenseEndpoint, doc.RefKey);
 0353                    if (fetched != null && !string.IsNullOrWhiteSpace(fetched.Number))
 0354                        docNumber = fetched.Number;
 0355                }
 356                // Отказ 1С провести документ — ожидаемый бизнес-исход (обычно не хватает
 357                // остатка): документ остаётся черновиком, а готовая причина уходит пользователю
 358                // в алерт транзакции. Это не сбой интеграции, поэтому Information: Warning и Error
 359                // попадают в errors-*.log и заводят автоматический issue на штатную ситуацию.
 0360                catch (OneCDocumentPostingException postingEx)
 361                {
 0362                    postingError = postingEx.Message;
 0363                    errorDetail = OneCResponseErrors.Describe(postingEx);
 0364                    _logger.LogInformation("[OData] 1С не провела расходной накладной {Guid}: {Reason}", doc.RefKey, pos
 0365                }
 0366                catch (Exception patchEx)
 367                {
 0368                    postingError = OneCODataErrorFormatter.Format(patchEx.Message);
 0369                    errorDetail = OneCResponseErrors.Describe(patchEx);
 0370                    _logger.LogError(patchEx, "[OData] Ошибка проведения расходной накладной {Guid}", doc.RefKey);
 0371                }
 372
 373                // 1С в ответе на /Post при нехватке остатка возвращает только общую фразу
 374                // «Не удалось провести "..."!» без детализации причины. Если в этом же документе
 375                // только что создана номенклатура — она гарантированно имеет нулевой остаток на
 376                // складе. Если ВСЕ строки документа — новая номенклатура, это точная причина
 377                // отказа; если только часть строк — это лишь вероятная причина (могло не хватить
 378                // остатка и по уже существующей позиции), формулировку смягчаем.
 0379                if (!isPosted && createdNomenclature.Any())
 380                {
 0381                    var namesJoined = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase));
 0382                    var causePrefix = lines.Count == createdNomenclature.Count
 0383                        ? "Не хватает остатка"
 0384                        : "Вероятно, не хватает остатка";
 0385                    postingError =
 0386                        $"{causePrefix} на складе «{warehouseName}»: только что созданная номенклатура ({namesJoined}) с
 387                }
 388            }
 389
 390            string message;
 0391            if (missingItems.Any())
 392            {
 0393                message = !lines.Any()
 0394                    ? $"Расходная {docNumber} создана как черновик — все позиции без номенклатуры 1С: {missingNomStr}"
 0395                    : $"Расходная {docNumber} создана как черновик — не найдена номенклатура: {missingNomStr}";
 396            }
 397            else
 398            {
 0399                message = createAsDraft
 0400                    ? $"Расходная накладная {docNumber} создана как черновик: {draftReason ?? "недостаточно остатка в 1С
 0401                    : isPosted
 0402                    ? $"Расходная накладная {docNumber} создана и проведена"
 0403                    : $"Расходная накладная {docNumber} создана, проведение не выполнено: {postingError}";
 404            }
 405
 0406            return new OneCResponse
 0407            {
 0408                Success = true,
 0409                Message = message,
 0410                TransactionId = doc.RefKey.ToString(),
 0411                DocumentNumber = string.IsNullOrWhiteSpace(docNumber) ? null : docNumber,
 0412                IsPosted = isPosted,
 0413                IsDraft = createAsDraft,
 0414                MissingNomenclature = missingNomStr,
 0415                CreatedNomenclature = string.Join(", ", createdNomenclature.Distinct(StringComparer.OrdinalIgnoreCase)),
 0416                PostingError = postingError,
 0417                ErrorDetail = errorDetail,
 0418                NomenclatureNotes = nomenclatureNotes.Count > 0 ? nomenclatureNotes : null
 0419            };
 420        }
 0421        catch (Exception ex)
 422        {
 0423            var msg = $"Ошибка при {operationLabel}: {ex.Message}";
 0424            _logger.LogError(ex, "[OData] {Msg}", msg);
 0425            return new OneCResponse { Success = false, Message = msg, ErrorDetail = OneCResponseErrors.Describe(ex) };
 426        }
 0427    }
 428
 0429    public Task<bool> GetTestRequest(string clientId, string apiKey) => TestConnectionAsync();
 430
 431    private async Task<bool> TestConnectionAsync()
 432    {
 433        try
 434        {
 0435            await _referenceService.GetWarehousesAsync();
 0436            return true;
 437        }
 0438        catch { return false; }
 0439    }
 440
 441    // ─── Вспомогательные методы ────────────────────────────────────────────────
 442
 443    private static string? GetZasylValue(Order? order)
 0444        => order?.OrderExtensions?
 0445            .FirstOrDefault(x => string.Equals(x.Key, ZasylKey, StringComparison.OrdinalIgnoreCase))
 0446            ?.Value?.Trim();
 447
 448    private static string? GetEffectiveWarehouseName(Order? order)
 449    {
 0450        var zasyl = GetZasylValue(order);
 0451        return !string.IsNullOrWhiteSpace(zasyl) ? zasyl : order?.ShipmentWarehouse?.Name?.Trim();
 452    }
 453
 454    private static string FormatODataDate(DateTime? documentDate)
 455    {
 0456        var tz = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time");
 0457        var effectiveDate = documentDate.HasValue
 0458            ? NormalizeToRussianTime(documentDate.Value, tz)
 0459            : TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
 0460        return effectiveDate.ToString("yyyy-MM-ddTHH:mm:ss");
 461    }
 462
 463    private static DateTime NormalizeToRussianTime(DateTime value, TimeZoneInfo tz)
 0464        => value.Kind switch
 0465        {
 0466            DateTimeKind.Utc => TimeZoneInfo.ConvertTimeFromUtc(value, tz),
 0467            DateTimeKind.Local => TimeZoneInfo.ConvertTime(value, TimeZoneInfo.Local, tz),
 0468            _ => value
 0469        };
 470
 471    private static string BuildTransactionTitle(TransactionType transactionType, string? userName)
 472    {
 0473        var userPart = string.IsNullOrWhiteSpace(userName) ? "Система" : userName.Trim();
 0474        return $"{transactionType.GetDisplayName()} (пользователь: {userPart})";
 475    }
 476
 477    private static string FormatMissingItem((string Article, string? ProductName, decimal Qty) item)
 0478        => string.IsNullOrWhiteSpace(item.ProductName)
 0479            ? $"{item.Article} ×{item.Qty}"
 0480            : $"{item.ProductName} [{item.Article}] ×{item.Qty}";
 481
 482    private static string BuildComment(
 483        TransactionType transactionType,
 484        string? userName,
 485        string clientName,
 486        IEnumerable<string?> shipmentNumbers,
 487        string? userComment = null)
 488    {
 0489        var title = BuildTransactionTitle(transactionType, userName);
 0490        var numbers = string.Join(", ", shipmentNumbers.Where(n => !string.IsNullOrEmpty(n)));
 0491        var auto = $"{title} для клиента {clientName}. Заказы: {numbers}";
 0492        return !string.IsNullOrWhiteSpace(userComment)
 0493            ? $"{title}. {userComment.Trim()}"
 0494            : auto;
 495    }
 496}