| | | 1 | | using Microsoft.EntityFrameworkCore; |
| | | 2 | | using Microsoft.Extensions.Caching.Memory; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | using Microsoft.Extensions.Options; |
| | | 5 | | using OzonRepositories.Context; |
| | | 6 | | using Services.ApiServices._1CApi.OData; |
| | | 7 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 8 | | |
| | | 9 | | namespace Services.BitrixStockServices; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Настройки сверки остатков отчёта с живыми остатками 1С. Секция <c>BitrixStock:LiveStockCompare</c>. |
| | | 13 | | /// Сейчас сверка только пишет расхождения в журнал отчёта и ничего не меняет в количествах. |
| | | 14 | | /// </summary> |
| | | 15 | | public 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> |
| | | 30 | | public sealed record LiveCompareRow(string Article, string OneCStore, int SheetQty, Guid? CardKey); |
| | | 31 | | |
| | | 32 | | public sealed record LiveCompareMismatch(string Article, string OneCStore, int SheetQty, decimal LiveQty) |
| | | 33 | | { |
| | | 34 | | public decimal Diff => SheetQty - LiveQty; |
| | | 35 | | } |
| | | 36 | | |
| | 7 | 37 | | public sealed record LiveCompareResult( |
| | 2 | 38 | | int Checked, |
| | 1 | 39 | | int Unmapped, |
| | 1 | 40 | | int Unverified, |
| | 17 | 41 | | IReadOnlyList<LiveCompareMismatch> Mismatches) |
| | | 42 | | { |
| | 2 | 43 | | public int SheetHigher => Mismatches.Count(m => m.Diff > 0); |
| | 4 | 44 | | public int SheetLower => Mismatches.Count(m => m.Diff < 0); |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | /// <summary>Чистая сверка без обращений к 1С и БД — тестируется отдельно.</summary> |
| | | 48 | | public 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 | | |
| | | 85 | | public interface IBitrixStockLiveStockComparer |
| | | 86 | | { |
| | | 87 | | /// <summary> |
| | | 88 | | /// Сверяет остатки строк готового отчёта с живыми остатками 1С и пишет расхождения в журнал. |
| | | 89 | | /// Ничего не меняет в отчёте. Никогда не бросает наружу: сбой сверки не должен ронять отчёт. |
| | | 90 | | /// </summary> |
| | | 91 | | Task CompareAsync(string reportId, Func<string, Task> log, CancellationToken ct = default); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | public 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>($@" |
| | | 150 | | SELECT l.Article AS Article, l.OneCStore AS OneCStore, MAX(l.OneCQty + COALESCE(l.ReservedQty, 0)) AS SheetQty |
| | | 151 | | FROM BitrixStockSyncReportLines l |
| | | 152 | | WHERE l.ReportId = {reportId} AND l.Article <> '' AND l.OneCStore <> '' |
| | | 153 | | GROUP 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 | | } |