< Summary

Information
Class: Services.ApiServices._1CApi.Stock.OneCStockAvailabilityService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Stock/OneCStockAvailabilityService.cs
Line coverage
100%
Covered lines: 93
Uncovered lines: 0
Coverable lines: 93
Total lines: 242
Line coverage: 100%
Branch coverage
85%
Covered branches: 34
Total branches: 40
Branch coverage: 85%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CheckAsync()75%44100%
CheckCoreAsync()90.9%2222100%
LoadBalancesAsync()83.33%66100%
.ctor(...)100%11100%
get_Article()100%11100%
get_ProductName()100%11100%
get_WarehouseName()100%11100%
get_Required()100%11100%
get_OrderIds()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Stock/OneCStockAvailabilityService.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Services.ApiServices._1CApi.OData;
 3using Services.ApiServices._1CApi.OData.Models;
 4
 5namespace Services.ApiServices._1CApi.Stock;
 6
 7/// <summary>Строка документа, которую нужно проверить: сколько товара уйдёт с какого склада.</summary>
 8public sealed record StockCheckLine(
 9    int OrderId,
 10    string? Article,
 11    string? ProductName,
 12    string? ProducerName,
 13    string? WarehouseName,
 14    decimal Quantity);
 15
 16public enum StockCheckStatus
 17{
 18    Ok,
 19    InsufficientStock,
 20    NomenclatureNotFound,
 21    WarehouseNotFound
 22}
 23
 24/// <summary>Результат по одной паре (карточка номенклатуры, склад).</summary>
 25public sealed record StockShortage(
 26    string Article,
 27    string? ProductName,
 28    string WarehouseName,
 29    decimal Required,
 30    decimal Available,
 31    StockCheckStatus Status,
 32    IReadOnlyList<int> OrderIds);
 33
 34public sealed record StockCheckResult(
 35    IReadOnlyList<StockShortage> Lines,
 36    bool CheckSkipped,
 37    string? SkipReason)
 38{
 39    /// <summary>Только то, что блокирует проведение. Отсутствие карточки и склада — не блокирует.</summary>
 40    public IReadOnlyList<StockShortage> Blocking { get; } =
 41        Lines.Where(l => l.Status == StockCheckStatus.InsufficientStock).ToList();
 42
 43    public bool HasBlocking => Blocking.Count > 0;
 44
 45    public static StockCheckResult Empty { get; } = new(Array.Empty<StockShortage>(), false, null);
 46
 47    public static StockCheckResult Skipped(string reason) =>
 48        new(Array.Empty<StockShortage>(), true, reason);
 49}
 50
 51public interface IOneCStockAvailabilityService
 52{
 53    /// <summary>
 54    /// Спрашивает у 1С текущие остатки и сравнивает их с потребностью документа.
 55    /// Никогда не бросает из-за недоступности 1С — возвращает CheckSkipped=true (fail-open).
 56    /// </summary>
 57    Task<StockCheckResult> CheckAsync(IReadOnlyList<StockCheckLine> lines, CancellationToken ct = default);
 58}
 59
 60/// <summary>
 61/// Проверяет остатки в 1С ДО создания документа. Нужно потому, что при нехватке остатка 1С
 62/// принимает создание документа, но отклоняет проведение, отдавая лишь общее
 63/// «Не удалось провести "..."!» без указания позиции — детализация видна только внутри 1С.
 64/// </summary>
 65public sealed class OneCStockAvailabilityService : IOneCStockAvailabilityService
 66{
 67    private const string BalanceEndpoint = "AccumulationRegister_ЗапасыНаСкладах/Balance";
 68
 69    /// <summary>
 70    /// Сколько GUID номенклатуры кладём в один $filter. Имя поля «Номенклатура_Key» кириллическое,
 71    /// после Uri.EscapeDataString один терм занимает ~150 байт: 20 термов ≈ 3 КБ query string —
 72    /// с запасом ниже риска HTTP 414, но документ на 40 позиций укладывается в 2 запроса вместо 40.
 73    /// </summary>
 74    private const int BalanceChunkSize = 20;
 75
 76    private readonly IOneCODataClient _client;
 77    private readonly IOneCODataReferenceService _referenceService;
 78    private readonly IOneCNomenclatureService _nomenclatureService;
 79    private readonly ILogger<OneCStockAvailabilityService> _logger;
 80
 1481    public OneCStockAvailabilityService(
 1482        IOneCODataClient client,
 1483        IOneCODataReferenceService referenceService,
 1484        IOneCNomenclatureService nomenclatureService,
 1485        ILogger<OneCStockAvailabilityService> logger)
 86    {
 1487        _client = client;
 1488        _referenceService = referenceService;
 1489        _nomenclatureService = nomenclatureService;
 1490        _logger = logger;
 1491    }
 92
 93    public async Task<StockCheckResult> CheckAsync(IReadOnlyList<StockCheckLine> lines, CancellationToken ct = default)
 94    {
 95        // Те же правила отбора, что у OneCTransferManager при построении строк документа:
 96        // позиции без артикула или с нулевым количеством в документ не попадают.
 1497        var valid = (lines ?? Array.Empty<StockCheckLine>())
 6398            .Where(l => !string.IsNullOrWhiteSpace(l.Article)
 6399                        && !string.IsNullOrWhiteSpace(l.WarehouseName)
 63100                        && l.Quantity > 0)
 14101            .ToList();
 102
 14103        if (valid.Count == 0)
 2104            return StockCheckResult.Empty;
 105
 106        try
 107        {
 12108            return await CheckCoreAsync(valid, ct);
 109        }
 1110        catch (Exception ex)
 111        {
 112            // Fail-open: 1С недоступна — не блокируем проведение. Настоящий backstop (ошибка
 113            // проведения документа) остаётся на месте, а блокировать создание документов на всё
 114            // время недоступности 1С было бы регрессом относительно текущего поведения.
 1115            _logger.LogWarning(ex, "[OData] Проверка остатков не выполнена — продолжаем без неё.");
 1116            return StockCheckResult.Skipped(ex.Message);
 117        }
 14118    }
 119
 120    private async Task<StockCheckResult> CheckCoreAsync(List<StockCheckLine> lines, CancellationToken ct)
 121    {
 12122        var warehouses = await _referenceService.GetWarehousesAsync();
 12123        var warehouseGuidByName = warehouses
 24124            .Where(w => !string.IsNullOrWhiteSpace(w.Description))
 24125            .GroupBy(w => w.Description!.Trim(), StringComparer.OrdinalIgnoreCase)
 60126            .ToDictionary(g => g.Key, g => g.First().RefKey, StringComparer.OrdinalIgnoreCase);
 127
 128        // Резолвим уникальные (артикул, производитель) — тем же путём, которым документ разрешает
 129        // номенклатуру, но без создания карточек.
 12130        var lookupKeys = lines
 60131            .Select(l => (Article: l.Article!.Trim(), Producer: l.ProducerName?.Trim() ?? string.Empty))
 12132            .Distinct()
 12133            .ToList();
 134
 12135        var found = await _nomenclatureService.FindManyAsync(
 70136            lookupKeys.Select(k => ((string?)k.Article, (string?)k.Producer)).ToList());
 137
 12138        var nomenclatureByKey = new Dictionary<(string Article, string Producer), Guid>();
 140139        for (var i = 0; i < lookupKeys.Count; i++)
 140        {
 58141            if (found[i] is { } refKey)
 57142                nomenclatureByKey[lookupKeys[i]] = refKey;
 143        }
 144
 145        // Группируем по (карточка, склад), а не по артикулу: разные написания артикула ведут на одну
 146        // карточку, а один артикул с разными производителями — на две.
 12147        var groups = new Dictionary<(Guid Nomenclature, Guid Warehouse), StockGroup>();
 12148        var unresolved = new List<StockShortage>();
 149
 144150        foreach (var line in lines)
 151        {
 60152            var article = line.Article!.Trim();
 60153            var warehouseName = line.WarehouseName!.Trim();
 60154            var key = (article, line.ProducerName?.Trim() ?? string.Empty);
 155
 60156            if (!nomenclatureByKey.TryGetValue(key, out var nomenclatureKey))
 157            {
 1158                unresolved.Add(new StockShortage(article, line.ProductName, warehouseName,
 1159                    line.Quantity, 0, StockCheckStatus.NomenclatureNotFound, new[] { line.OrderId }));
 1160                continue;
 161            }
 162
 59163            if (!warehouseGuidByName.TryGetValue(warehouseName, out var warehouseKey))
 164            {
 1165                unresolved.Add(new StockShortage(article, line.ProductName, warehouseName,
 1166                    line.Quantity, 0, StockCheckStatus.WarehouseNotFound, new[] { line.OrderId }));
 1167                continue;
 168            }
 169
 58170            if (!groups.TryGetValue((nomenclatureKey, warehouseKey), out var group))
 171            {
 56172                group = new StockGroup(article, line.ProductName, warehouseName);
 56173                groups[(nomenclatureKey, warehouseKey)] = group;
 174            }
 175
 58176            group.Required += line.Quantity;
 58177            group.OrderIds.Add(line.OrderId);
 178        }
 179
 12180        if (groups.Count == 0)
 2181            return new StockCheckResult(unresolved, false, null);
 182
 66183        var balances = await LoadBalancesAsync(groups.Keys.Select(k => k.Nomenclature).Distinct().ToList(), ct);
 184
 9185        var result = new List<StockShortage>(groups.Count + unresolved.Count);
 128186        foreach (var ((nomenclatureKey, warehouseKey), group) in groups)
 187        {
 188            // Виртуальная таблица не возвращает нулевые строки — отсутствие пары значит остаток 0.
 55189            balances.TryGetValue((nomenclatureKey, warehouseKey), out var available);
 55190            result.Add(new StockShortage(
 55191                group.Article,
 55192                group.ProductName,
 55193                group.WarehouseName,
 55194                group.Required,
 55195                available,
 55196                group.Required > available ? StockCheckStatus.InsufficientStock : StockCheckStatus.Ok,
 55197                group.OrderIds));
 198        }
 199
 9200        result.AddRange(unresolved);
 9201        return new StockCheckResult(result, false, null);
 11202    }
 203
 204    private async Task<Dictionary<(Guid Nomenclature, Guid Warehouse), decimal>> LoadBalancesAsync(
 205        IReadOnlyList<Guid> nomenclatureKeys,
 206        CancellationToken ct)
 207    {
 10208        var balances = new Dictionary<(Guid, Guid), decimal>();
 209
 210        // Фильтруем только по номенклатуре: складов в документе обычно 1-2, а OR-of-AND с
 211        // «СтруктурнаяЕдиница_Key» удвоил бы длину фильтра. Склады сводим локально.
 43212        foreach (var chunk in nomenclatureKeys.Chunk(BalanceChunkSize))
 213        {
 214            // Отбор идёт параметром Condition самой виртуальной таблицы: $filter по её измерениям
 215            // 1С Fresh запрещает («Операция не разрешена в предложении "ГДЕ"», HTTP 500).
 67216            var condition = string.Join(" or ", chunk.Select(k => $"Номенклатура_Key eq guid'{k}'"));
 12217            var rows = await _client.GetListAsync<ODataStockBalance>(
 12218                OneCRegisterEndpoint.WithCondition(BalanceEndpoint, condition),
 12219                queryParams: null,
 12220                ct);
 221
 40222            foreach (var row in rows)
 223            {
 9224                var key = (row.NomenclatureKey, row.WarehouseKey);
 9225                balances[key] = balances.TryGetValue(key, out var existing)
 9226                    ? existing + row.QuantityBalance
 9227                    : row.QuantityBalance;
 228            }
 229        }
 230
 9231        return balances;
 9232    }
 233
 56234    private sealed class StockGroup(string article, string? productName, string warehouseName)
 235    {
 111236        public string Article { get; } = article;
 111237        public string? ProductName { get; } = productName;
 111238        public string WarehouseName { get; } = warehouseName;
 226239        public decimal Required { get; set; }
 169240        public List<int> OrderIds { get; } = [];
 241    }
 242}