< Summary

Information
Class: Services.BitrixStockServices.LiveCompareResult
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockLiveStockComparer.cs
Line coverage
100%
Covered lines: 7
Uncovered lines: 0
Coverable lines: 7
Total lines: 240
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_Checked()100%11100%
get_Unmapped()100%11100%
get_Unverified()100%11100%
get_Mismatches()100%11100%
get_SheetHigher()100%11100%
get_SheetLower()100%11100%

File(s)

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

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Caching.Memory;
 3using Microsoft.Extensions.Logging;
 4using Microsoft.Extensions.Options;
 5using OzonRepositories.Context;
 6using Services.ApiServices._1CApi.OData;
 7using Services.ApiServices._1CApi.OData.Models;
 8
 9namespace Services.BitrixStockServices;
 10
 11/// <summary>
 12/// Настройки сверки остатков отчёта с живыми остатками 1С. Секция <c>BitrixStock:LiveStockCompare</c>.
 13/// Сейчас сверка только пишет расхождения в журнал отчёта и ничего не меняет в количествах.
 14/// </summary>
 15public sealed class BitrixStockLiveStockOptions
 16{
 17    public const string SectionName = "BitrixStock:LiveStockCompare";
 18
 19    /// <summary>Выключено по умолчанию: включается явно, чтобы не добавлять нагрузку на 1С без решения.</summary>
 20    public bool Enabled { get; set; }
 21
 22    /// <summary>Сколько минут держим остатки склада 1С в памяти (на цикл отчётов хватает одной загрузки).</summary>
 23    public int CacheMinutes { get; set; } = 5;
 24
 25    /// <summary>Сколько расхождений выводить в журнал отчёта (остальные только считаются).</summary>
 26    public int MaxLoggedMismatches { get; set; } = 40;
 27}
 28
 29/// <summary>Строка отчёта для сверки: остаток 1С по отчёту до вычета резерва (то, что дала таблица).</summary>
 30public sealed record LiveCompareRow(string Article, string OneCStore, int SheetQty, Guid? CardKey);
 31
 32public sealed record LiveCompareMismatch(string Article, string OneCStore, int SheetQty, decimal LiveQty)
 33{
 34    public decimal Diff => SheetQty - LiveQty;
 35}
 36
 737public sealed record LiveCompareResult(
 238    int Checked,
 139    int Unmapped,
 140    int Unverified,
 1741    IReadOnlyList<LiveCompareMismatch> Mismatches)
 42{
 243    public int SheetHigher => Mismatches.Count(m => m.Diff > 0);
 444    public int SheetLower => Mismatches.Count(m => m.Diff < 0);
 45}
 46
 47/// <summary>Чистая сверка без обращений к 1С и БД — тестируется отдельно.</summary>
 48public static class BitrixStockLiveStockComparison
 49{
 50    public static LiveCompareResult Compare(
 51        IReadOnlyList<LiveCompareRow> rows,
 52        IReadOnlyDictionary<string, IReadOnlyDictionary<Guid, decimal>> balancesByStore)
 53    {
 54        var mismatches = new List<LiveCompareMismatch>();
 55        int checkedRows = 0, unmapped = 0, unverified = 0;
 56
 57        foreach (var row in rows)
 58        {
 59            if (row.CardKey is not { } card)
 60            {
 61                unmapped++;
 62                continue;
 63            }
 64
 65            var stores = BitrixStockLiveStockClamp.SplitStores(row.OneCStore);
 66            if (stores.Count == 0 || stores.Any(s => !balancesByStore.ContainsKey(s)))
 67            {
 68                unverified++;
 69                continue;
 70            }
 71
 72            checkedRows++;
 73            // Виртуальная таблица не возвращает нулевые строки: нет записи — остаток 0.
 74            var live = stores.Sum(s => balancesByStore[s].GetValueOrDefault(card));
 75            if (Math.Floor(live) != row.SheetQty)
 76                mismatches.Add(new LiveCompareMismatch(row.Article, row.OneCStore, row.SheetQty, live));
 77        }
 78
 79        return new LiveCompareResult(
 80            checkedRows, unmapped, unverified,
 81            mismatches.OrderByDescending(m => Math.Abs(m.Diff)).ThenBy(m => m.Article, StringComparer.OrdinalIgnoreCase)
 82    }
 83}
 84
 85public interface IBitrixStockLiveStockComparer
 86{
 87    /// <summary>
 88    /// Сверяет остатки строк готового отчёта с живыми остатками 1С и пишет расхождения в журнал.
 89    /// Ничего не меняет в отчёте. Никогда не бросает наружу: сбой сверки не должен ронять отчёт.
 90    /// </summary>
 91    Task CompareAsync(string reportId, Func<string, Task> log, CancellationToken ct = default);
 92}
 93
 94public sealed class BitrixStockLiveStockComparer(
 95    IDbContextFactory<ApplicationDbContext> dbFactory,
 96    IOneCODataClient oneC,
 97    IOneCODataReferenceService referenceService,
 98    IOneCNomenclatureMapStore mapStore,
 99    IMemoryCache cache,
 100    IOptions<BitrixStockLiveStockOptions> options,
 101    ILogger<BitrixStockLiveStockComparer> logger) : IBitrixStockLiveStockComparer
 102{
 103    private const string BalanceEndpoint = "AccumulationRegister_ЗапасыНаСкладах/Balance";
 104    private const string MapKeysCacheKey = "bitrix-live-stock:map-keys";
 105
 106    public async Task CompareAsync(string reportId, Func<string, Task> log, CancellationToken ct = default)
 107    {
 108        var settings = options.Value;
 109        if (!settings.Enabled)
 110            return;
 111
 112        try
 113        {
 114            var rows = await LoadReportRowsAsync(reportId, ct);
 115            if (rows.Count == 0)
 116                return;
 117
 118            var stores = rows.SelectMany(r => BitrixStockLiveStockClamp.SplitStores(r.OneCStore))
 119                .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 120            var balances = await LoadBalancesAsync(stores, settings, ct);
 121            var cards = await ResolveCardsAsync(rows, ct);
 122
 123            var result = BitrixStockLiveStockComparison.Compare(
 124                rows.Select(r => new LiveCompareRow(r.Article, r.OneCStore, r.SheetQty, cards.GetValueOrDefault(r.Articl
 125                balances);
 126
 127            await log($"Сверка с 1С онлайн (только сравнение, количества не менялись): проверено {result.Checked} строк,
 128                      $"расхождений {result.Mismatches.Count} (таблица больше 1С: {result.SheetHigher}, меньше: {result.
 129                      $"карточка не найдена: {result.Unmapped}, склад не определён: {result.Unverified}.");
 130            foreach (var m in result.Mismatches.Take(Math.Max(0, settings.MaxLoggedMismatches)))
 131                await log($"⚖ {m.Article} ({m.OneCStore}): таблица {m.SheetQty}, 1С {m.LiveQty:0.##}");
 132            if (result.Mismatches.Count > settings.MaxLoggedMismatches)
 133                await log($"… и ещё {result.Mismatches.Count - settings.MaxLoggedMismatches} расхождений.");
 134        }
 135        catch (OperationCanceledException) { throw; }
 136        catch (Exception ex)
 137        {
 138            logger.LogWarning(ex, "Сверка остатков отчёта {ReportId} с 1С не выполнена", reportId);
 139            await log($"Предупреждение: сверка с 1С онлайн не выполнена — {ex.Message}");
 140        }
 141    }
 142
 143    private sealed record ReportRow(string Article, string OneCStore, int SheetQty);
 144
 145    private async Task<List<ReportRow>> LoadReportRowsAsync(string reportId, CancellationToken ct)
 146    {
 147        await using var db = await dbFactory.CreateDbContextAsync(ct);
 148        // Одна и та же пара «артикул + склад 1С» может быть у нескольких клиентов Ozon — остаток 1С у неё общий.
 149        var rows = await db.Database.SqlQuery<ReportRowDb>($@"
 150SELECT l.Article AS Article, l.OneCStore AS OneCStore, MAX(l.OneCQty + COALESCE(l.ReservedQty, 0)) AS SheetQty
 151FROM BitrixStockSyncReportLines l
 152WHERE l.ReportId = {reportId} AND l.Article <> '' AND l.OneCStore <> ''
 153GROUP BY l.Article, l.OneCStore").ToListAsync(ct);
 154        return rows.Select(r => new ReportRow(r.Article, r.OneCStore, (int)r.SheetQty)).ToList();
 155    }
 156
 157    private sealed class ReportRowDb
 158    {
 159        public string Article { get; set; } = string.Empty;
 160        public string OneCStore { get; set; } = string.Empty;
 161        public long SheetQty { get; set; }
 162    }
 163
 164    private async Task<Dictionary<string, IReadOnlyDictionary<Guid, decimal>>> LoadBalancesAsync(
 165        IReadOnlyList<string> storeTitles, BitrixStockLiveStockOptions settings, CancellationToken ct)
 166    {
 167        var warehouses = await referenceService.GetWarehousesAsync();
 168        var guidByTitle = warehouses
 169            .Where(w => !string.IsNullOrWhiteSpace(w.Description))
 170            .GroupBy(w => w.Description!.Trim(), StringComparer.OrdinalIgnoreCase)
 171            .ToDictionary(g => g.Key, g => g.First().RefKey, StringComparer.OrdinalIgnoreCase);
 172
 173        var result = new Dictionary<string, IReadOnlyDictionary<Guid, decimal>>(StringComparer.OrdinalIgnoreCase);
 174        foreach (var title in storeTitles)
 175        {
 176            if (!guidByTitle.TryGetValue(title, out var warehouseKey))
 177                continue; // склад не найден в 1С — строки с ним попадут в «склад не определён»
 178
 179            var cacheKey = $"bitrix-live-stock:balance:{warehouseKey}";
 180            if (!cache.TryGetValue(cacheKey, out IReadOnlyDictionary<Guid, decimal>? balance) || balance == null)
 181            {
 182                // Один запрос на склад без пагинации: виртуальная таблица отдаёт все строки склада сразу,
 183                // а $skip на ней не гарантирован. Отбор — параметром Condition (не $filter).
 184                var rows = await oneC.GetSinglePageAsync<ODataStockBalance>(
 185                    OneCRegisterEndpoint.WithCondition(BalanceEndpoint, $"СтруктурнаяЕдиница_Key eq guid'{warehouseKey}'
 186                    top: 200_000);
 187                balance = rows.GroupBy(r => r.NomenclatureKey).ToDictionary(g => g.Key, g => g.Sum(x => x.QuantityBalanc
 188                cache.Set(cacheKey, balance, TimeSpan.FromMinutes(Math.Max(1, settings.CacheMinutes)));
 189            }
 190
 191            result[title] = balance;
 192        }
 193
 194        return result;
 195    }
 196
 197    /// <summary>Артикул → карточка 1С. Производитель в строке отчёта нет — берём из снимка; неоднозначные не сопоставля
 198    private async Task<Dictionary<string, Guid>> ResolveCardsAsync(IReadOnlyList<ReportRow> rows, CancellationToken ct)
 199    {
 200        var articles = rows.Select(r => r.Article).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 201        var manufacturers = new List<(string Article, string Manufacturer)>();
 202        await using (var db = await dbFactory.CreateDbContextAsync(ct))
 203        {
 204            foreach (var chunk in articles.Chunk(500))
 205            {
 206                var part = await db.WarehouseStockSnapshots.AsNoTracking()
 207                    .Where(s => chunk.Contains(s.Article) && s.Manufacturer != null && s.Manufacturer != "")
 208                    .Select(s => new { s.Article, s.Manufacturer })
 209                    .Distinct()
 210                    .ToListAsync(ct);
 211                manufacturers.AddRange(part.Select(p => (p.Article ?? string.Empty, p.Manufacturer!)));
 212            }
 213        }
 214
 215        var mapKeys = await GetMapKeysAsync(ct);
 216        var result = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
 217        foreach (var group in manufacturers.GroupBy(m => m.Article, StringComparer.OrdinalIgnoreCase))
 218        {
 219            var producers = group.Select(g => g.Manufacturer).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
 220            if (producers.Count != 1)
 221                continue; // один артикул у нескольких производителей — карточку не угадать
 222
 223            var key = (OneCNomenclatureService.NormalizeArticle(group.Key), OneCNomenclatureService.NormalizeProducerNam
 224            if (mapKeys.TryGetValue(key, out var card))
 225                result[group.Key] = card;
 226        }
 227
 228        return result;
 229    }
 230
 231    private async Task<IReadOnlyDictionary<(string ArticleNorm, string ProducerNorm), Guid>> GetMapKeysAsync(Cancellatio
 232    {
 233        if (cache.TryGetValue(MapKeysCacheKey, out IReadOnlyDictionary<(string, string), Guid>? cached) && cached != nul
 234            return cached;
 235
 236        var all = await mapStore.GetAllKeysAsync(ct);
 237        cache.Set(MapKeysCacheKey, all, TimeSpan.FromMinutes(10));
 238        return all;
 239    }
 240}