< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_OrderId()100%11100%
get_Article()100%11100%
get_ProductName()100%11100%
get_ProducerName()100%11100%
get_WarehouseName()100%11100%
get_Quantity()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>
 868public sealed record StockCheckLine(
 669    int OrderId,
 18310    string? Article,
 5811    string? ProductName,
 12112    string? ProducerName,
 14013    string? WarehouseName,
 21114    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
 81    public OneCStockAvailabilityService(
 82        IOneCODataClient client,
 83        IOneCODataReferenceService referenceService,
 84        IOneCNomenclatureService nomenclatureService,
 85        ILogger<OneCStockAvailabilityService> logger)
 86    {
 87        _client = client;
 88        _referenceService = referenceService;
 89        _nomenclatureService = nomenclatureService;
 90        _logger = logger;
 91    }
 92
 93    public async Task<StockCheckResult> CheckAsync(IReadOnlyList<StockCheckLine> lines, CancellationToken ct = default)
 94    {
 95        // Те же правила отбора, что у OneCTransferManager при построении строк документа:
 96        // позиции без артикула или с нулевым количеством в документ не попадают.
 97        var valid = (lines ?? Array.Empty<StockCheckLine>())
 98            .Where(l => !string.IsNullOrWhiteSpace(l.Article)
 99                        && !string.IsNullOrWhiteSpace(l.WarehouseName)
 100                        && l.Quantity > 0)
 101            .ToList();
 102
 103        if (valid.Count == 0)
 104            return StockCheckResult.Empty;
 105
 106        try
 107        {
 108            return await CheckCoreAsync(valid, ct);
 109        }
 110        catch (Exception ex)
 111        {
 112            // Fail-open: 1С недоступна — не блокируем проведение. Настоящий backstop (ошибка
 113            // проведения документа) остаётся на месте, а блокировать создание документов на всё
 114            // время недоступности 1С было бы регрессом относительно текущего поведения.
 115            _logger.LogWarning(ex, "[OData] Проверка остатков не выполнена — продолжаем без неё.");
 116            return StockCheckResult.Skipped(ex.Message);
 117        }
 118    }
 119
 120    private async Task<StockCheckResult> CheckCoreAsync(List<StockCheckLine> lines, CancellationToken ct)
 121    {
 122        var warehouses = await _referenceService.GetWarehousesAsync();
 123        var warehouseGuidByName = warehouses
 124            .Where(w => !string.IsNullOrWhiteSpace(w.Description))
 125            .GroupBy(w => w.Description!.Trim(), StringComparer.OrdinalIgnoreCase)
 126            .ToDictionary(g => g.Key, g => g.First().RefKey, StringComparer.OrdinalIgnoreCase);
 127
 128        // Резолвим уникальные (артикул, производитель) — тем же путём, которым документ разрешает
 129        // номенклатуру, но без создания карточек.
 130        var lookupKeys = lines
 131            .Select(l => (Article: l.Article!.Trim(), Producer: l.ProducerName?.Trim() ?? string.Empty))
 132            .Distinct()
 133            .ToList();
 134
 135        var found = await _nomenclatureService.FindManyAsync(
 136            lookupKeys.Select(k => ((string?)k.Article, (string?)k.Producer)).ToList());
 137
 138        var nomenclatureByKey = new Dictionary<(string Article, string Producer), Guid>();
 139        for (var i = 0; i < lookupKeys.Count; i++)
 140        {
 141            if (found[i] is { } refKey)
 142                nomenclatureByKey[lookupKeys[i]] = refKey;
 143        }
 144
 145        // Группируем по (карточка, склад), а не по артикулу: разные написания артикула ведут на одну
 146        // карточку, а один артикул с разными производителями — на две.
 147        var groups = new Dictionary<(Guid Nomenclature, Guid Warehouse), StockGroup>();
 148        var unresolved = new List<StockShortage>();
 149
 150        foreach (var line in lines)
 151        {
 152            var article = line.Article!.Trim();
 153            var warehouseName = line.WarehouseName!.Trim();
 154            var key = (article, line.ProducerName?.Trim() ?? string.Empty);
 155
 156            if (!nomenclatureByKey.TryGetValue(key, out var nomenclatureKey))
 157            {
 158                unresolved.Add(new StockShortage(article, line.ProductName, warehouseName,
 159                    line.Quantity, 0, StockCheckStatus.NomenclatureNotFound, new[] { line.OrderId }));
 160                continue;
 161            }
 162
 163            if (!warehouseGuidByName.TryGetValue(warehouseName, out var warehouseKey))
 164            {
 165                unresolved.Add(new StockShortage(article, line.ProductName, warehouseName,
 166                    line.Quantity, 0, StockCheckStatus.WarehouseNotFound, new[] { line.OrderId }));
 167                continue;
 168            }
 169
 170            if (!groups.TryGetValue((nomenclatureKey, warehouseKey), out var group))
 171            {
 172                group = new StockGroup(article, line.ProductName, warehouseName);
 173                groups[(nomenclatureKey, warehouseKey)] = group;
 174            }
 175
 176            group.Required += line.Quantity;
 177            group.OrderIds.Add(line.OrderId);
 178        }
 179
 180        if (groups.Count == 0)
 181            return new StockCheckResult(unresolved, false, null);
 182
 183        var balances = await LoadBalancesAsync(groups.Keys.Select(k => k.Nomenclature).Distinct().ToList(), ct);
 184
 185        var result = new List<StockShortage>(groups.Count + unresolved.Count);
 186        foreach (var ((nomenclatureKey, warehouseKey), group) in groups)
 187        {
 188            // Виртуальная таблица не возвращает нулевые строки — отсутствие пары значит остаток 0.
 189            balances.TryGetValue((nomenclatureKey, warehouseKey), out var available);
 190            result.Add(new StockShortage(
 191                group.Article,
 192                group.ProductName,
 193                group.WarehouseName,
 194                group.Required,
 195                available,
 196                group.Required > available ? StockCheckStatus.InsufficientStock : StockCheckStatus.Ok,
 197                group.OrderIds));
 198        }
 199
 200        result.AddRange(unresolved);
 201        return new StockCheckResult(result, false, null);
 202    }
 203
 204    private async Task<Dictionary<(Guid Nomenclature, Guid Warehouse), decimal>> LoadBalancesAsync(
 205        IReadOnlyList<Guid> nomenclatureKeys,
 206        CancellationToken ct)
 207    {
 208        var balances = new Dictionary<(Guid, Guid), decimal>();
 209
 210        // Фильтруем только по номенклатуре: складов в документе обычно 1-2, а OR-of-AND с
 211        // «СтруктурнаяЕдиница_Key» удвоил бы длину фильтра. Склады сводим локально.
 212        foreach (var chunk in nomenclatureKeys.Chunk(BalanceChunkSize))
 213        {
 214            // Отбор идёт параметром Condition самой виртуальной таблицы: $filter по её измерениям
 215            // 1С Fresh запрещает («Операция не разрешена в предложении "ГДЕ"», HTTP 500).
 216            var condition = string.Join(" or ", chunk.Select(k => $"Номенклатура_Key eq guid'{k}'"));
 217            var rows = await _client.GetListAsync<ODataStockBalance>(
 218                OneCRegisterEndpoint.WithCondition(BalanceEndpoint, condition),
 219                queryParams: null,
 220                ct);
 221
 222            foreach (var row in rows)
 223            {
 224                var key = (row.NomenclatureKey, row.WarehouseKey);
 225                balances[key] = balances.TryGetValue(key, out var existing)
 226                    ? existing + row.QuantityBalance
 227                    : row.QuantityBalance;
 228            }
 229        }
 230
 231        return balances;
 232    }
 233
 234    private sealed class StockGroup(string article, string? productName, string warehouseName)
 235    {
 236        public string Article { get; } = article;
 237        public string? ProductName { get; } = productName;
 238        public string WarehouseName { get; } = warehouseName;
 239        public decimal Required { get; set; }
 240        public List<int> OrderIds { get; } = [];
 241    }
 242}