< Summary

Information
Class: Services.BitrixStockServices.BitrixStockLiveStockGuard
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockLiveStockGuard.cs
Line coverage
100%
Covered lines: 28
Uncovered lines: 0
Coverable lines: 28
Total lines: 97
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetLiveFreeStockAsync()100%2020100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockLiveStockGuard.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Services.ApiServices._1CApi.Stock;
 3
 4namespace Services.BitrixStockServices;
 5
 6/// <summary>Строка наката, остаток которой нужно сверить с 1С онлайн.</summary>
 7/// <param name="Stores">Названия складов 1С, которые суммируются в строку (например B1 и B2 → B12).</param>
 8/// <param name="ReservedQty">Резерв заказов, уже вычтенный из остатка 1С в отчёте.</param>
 9public sealed record LiveStockLine(long LineId, string Article, string? Producer, IReadOnlyList<string> Stores, int Rese
 10
 11public interface IBitrixStockLiveStockGuard
 12{
 13    /// <summary>
 14    /// Свободный остаток по 1С прямо сейчас (остаток регистра минус резерв) для каждой строки, которую
 15    /// удалось проверить. Строки без ответа 1С (карточка или склад не найдены, 1С недоступна) в результат
 16    /// не попадают — накат по ним идёт как раньше (fail-open).
 17    /// </summary>
 18    Task<IReadOnlyDictionary<long, int>> GetLiveFreeStockAsync(
 19        IReadOnlyList<LiveStockLine> lines, CancellationToken ct = default);
 20}
 21
 22/// <summary>
 23/// Страховка этапа 2: остатки отчёта берутся из снимка Google-таблицы, а она может отставать от 1С
 24/// (товар уже перемещён, в таблице всё ещё числится). Перед накатом остатка вверх сверяем его с регистром 1С.
 25/// </summary>
 626public sealed class BitrixStockLiveStockGuard(
 627    IOneCStockAvailabilityService availability,
 628    ILogger<BitrixStockLiveStockGuard> logger) : IBitrixStockLiveStockGuard
 29{
 30    public async Task<IReadOnlyDictionary<long, int>> GetLiveFreeStockAsync(
 31        IReadOnlyList<LiveStockLine> lines, CancellationToken ct = default)
 32    {
 633        var empty = new Dictionary<long, int>();
 634        if (lines.Count == 0)
 135            return empty;
 36
 37        // OrderId в запросе проверки остатков — индекс строки: по нему же сопоставляем ответ.
 538        var checks = new List<StockCheckLine>();
 2039        for (var i = 0; i < lines.Count; i++)
 40        {
 2241            foreach (var store in lines[i].Stores.Distinct(StringComparer.OrdinalIgnoreCase))
 642                checks.Add(new StockCheckLine(i, lines[i].Article, null, lines[i].Producer, store, 1));
 43        }
 44
 545        var result = await availability.CheckAsync(checks, ct);
 546        if (result.CheckSkipped)
 47        {
 148            logger.LogWarning("Сверка остатков с 1С пропущена: {Reason}", result.SkipReason);
 149            return empty;
 50        }
 51
 452        var liveByIndex = new Dictionary<int, decimal>();
 453        var unverified = new HashSet<int>();
 1854        foreach (var shortage in result.Lines)
 55        {
 2056            foreach (var index in shortage.OrderIds.Distinct())
 57            {
 558                if (shortage.Status is StockCheckStatus.NomenclatureNotFound or StockCheckStatus.WarehouseNotFound)
 59                {
 260                    unverified.Add(index);
 261                    continue;
 62                }
 63
 364                liveByIndex[index] = liveByIndex.GetValueOrDefault(index) + shortage.Available;
 65            }
 66        }
 67
 468        var free = new Dictionary<long, int>();
 1269        foreach (var (index, live) in liveByIndex)
 70        {
 271            if (unverified.Contains(index))
 72                continue;
 273            free[lines[index].LineId] = BitrixStockLiveStockClamp.FreeStock(live, lines[index].ReservedQty);
 74        }
 75
 476        return free;
 677    }
 78}
 79
 80/// <summary>Чистая арифметика защиты — отдельно от обращений к 1С, чтобы тестировать без сети.</summary>
 81public static class BitrixStockLiveStockClamp
 82{
 83    /// <summary>Остаток регистра 1С минус резерв заказов, не меньше нуля, целое число штук.</summary>
 84    public static int FreeStock(decimal liveBalance, int reservedQty) =>
 85        (int)Math.Max(0m, Math.Floor(liveBalance) - Math.Max(0, reservedQty));
 86
 87    /// <summary>
 88    /// Остаток к накату не может превышать свободный остаток по 1С. Возвращает ограниченное значение.
 89    /// </summary>
 90    public static int Clamp(int oneCQty, int freeLiveStock) => Math.Min(oneCQty, Math.Max(0, freeLiveStock));
 91
 92    /// <summary>«B1, B2» → [B1, B2]: названия складов 1С, склеенные в одну строку отчёта.</summary>
 93    public static IReadOnlyList<string> SplitStores(string? oneCStore) =>
 94        (oneCStore ?? string.Empty)
 95            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 96            .ToList();
 97}