| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | using System.Globalization; |
| | | 3 | | using Microsoft.Extensions.Caching.Memory; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using Services.ApiServices._1CApi.OData; |
| | | 6 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 7 | | using Services.BackgroundJobsServices; |
| | | 8 | | |
| | | 9 | | namespace Services.ApiServices._1CApi.Stock; |
| | | 10 | | |
| | | 11 | | public interface IOneCWarehouseStockService |
| | | 12 | | { |
| | | 13 | | /// <summary>Склады для выпадающего списка. При недоступности 1С возвращает пустой список.</summary> |
| | | 14 | | Task<IReadOnlyList<ODataWarehouse>> GetWarehousesAsync(CancellationToken ct = default); |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Остатки склада с фильтрами, сортировкой и пагинацией. Никогда не бросает: |
| | | 18 | | /// ошибки 1С возвращаются в результате как Failed/Warning. |
| | | 19 | | /// </summary> |
| | | 20 | | Task<OneCWarehouseStockResult> QueryAsync(OneCWarehouseStockQuery query, CancellationToken ct = default); |
| | | 21 | | } |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// Раздел «Остатки 1С по складам»: онлайн-выборка из OData без промежуточных таблиц. |
| | | 25 | | /// |
| | | 26 | | /// Стоимость запросов к 1С сильно разная, поэтому работа разложена по слоям: снимок остатков |
| | | 27 | | /// и обороты считаются на весь склад и кэшируются, карта последних приходов строится по всем |
| | | 28 | | /// документам базы и кэшируется глобально, а наименования карточек догружаются только для |
| | | 29 | | /// видимой страницы. |
| | | 30 | | /// |
| | | 31 | | /// Важно про <c>$filter</c>: 1С Fresh отвечает HTTP 500 на любое сравнение со ссылкой или датой |
| | | 32 | | /// (<c>Ref_Key eq guid'…'</c>, <c>СтруктурнаяЕдиница_Key eq guid'…'</c>, <c>Date ge datetime'…'</c>) |
| | | 33 | | /// у справочников, документов и их табличных частей, а если такое условие стоит после <c>and</c> — |
| | | 34 | | /// молча его отбрасывает и отдаёт всё. Поэтому у документов в 1С уходят только булевы условия, |
| | | 35 | | /// отбор по складу делается в памяти, а строки табличных частей читаются вложенным полем заголовка. |
| | | 36 | | /// </summary> |
| | | 37 | | public sealed class OneCWarehouseStockService : IOneCWarehouseStockService |
| | | 38 | | { |
| | | 39 | | private const string BalanceEndpoint = "AccumulationRegister_ЗапасыНаСкладах/Balance"; |
| | | 40 | | private const string NomenclatureEndpoint = "Catalog_Номенклатура"; |
| | | 41 | | private const string ReceiptHeaderEndpoint = "Document_ПриходнаяНакладная"; |
| | | 42 | | private const string TransferHeaderEndpoint = "Document_ПеремещениеЗапасов"; |
| | | 43 | | |
| | | 44 | | /// <summary>Единственный фильтр, который 1С Fresh применяет к документам честно.</summary> |
| | | 45 | | private const string PostedDocumentsFilter = "Posted eq true and DeletionMark eq false"; |
| | | 46 | | |
| | | 47 | | public const string ReceiptKind = "Приходная накладная"; |
| | | 48 | | public const string TransferKind = "Перемещение запасов"; |
| | | 49 | | |
| | | 50 | | /// <summary>Одновременных запросов к 1С — как в OneCReceiptWarehouseHistoryResolver.</summary> |
| | | 51 | | private const int MaxConcurrentRequests = 4; |
| | | 52 | | |
| | | 53 | | /// <summary>Страница 1С; полная страница означает, что за ней надо читать следующую.</summary> |
| | | 54 | | private const int DocumentPageSize = 1000; |
| | | 55 | | |
| | 1 | 56 | | private static readonly TimeSpan SnapshotTtl = TimeSpan.FromMinutes(10); |
| | 1 | 57 | | private static readonly TimeSpan TurnoverTtl = TimeSpan.FromMinutes(15); |
| | 1 | 58 | | private static readonly TimeSpan AgeGridTtl = TimeSpan.FromMinutes(30); |
| | | 59 | | /// <summary>Все проведённые накладные и перемещения базы — самый тяжёлый слой, живёт дольше остальных.</summary> |
| | 1 | 60 | | private static readonly TimeSpan DocumentsTtl = TimeSpan.FromMinutes(30); |
| | 1 | 61 | | private static readonly TimeSpan NomenclatureTtl = TimeSpan.FromHours(6); |
| | | 62 | | |
| | | 63 | | private readonly IOneCODataClient _client; |
| | | 64 | | private readonly IOneCODataReferenceService _referenceService; |
| | | 65 | | private readonly IOneCNomenclatureMapStore _mapStore; |
| | | 66 | | private readonly IMemoryCache _cache; |
| | | 67 | | private readonly ILogger<OneCWarehouseStockService> _logger; |
| | 35 | 68 | | private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(); |
| | | 69 | | |
| | 35 | 70 | | public OneCWarehouseStockService( |
| | 35 | 71 | | IOneCODataClient client, |
| | 35 | 72 | | IOneCODataReferenceService referenceService, |
| | 35 | 73 | | IOneCNomenclatureMapStore mapStore, |
| | 35 | 74 | | IMemoryCache cache, |
| | 35 | 75 | | ILogger<OneCWarehouseStockService> logger) |
| | | 76 | | { |
| | 35 | 77 | | _client = client; |
| | 35 | 78 | | _referenceService = referenceService; |
| | 35 | 79 | | _mapStore = mapStore; |
| | 35 | 80 | | _cache = cache; |
| | 35 | 81 | | _logger = logger; |
| | 35 | 82 | | } |
| | | 83 | | |
| | | 84 | | public async Task<IReadOnlyList<ODataWarehouse>> GetWarehousesAsync(CancellationToken ct = default) |
| | | 85 | | { |
| | | 86 | | try |
| | | 87 | | { |
| | 1 | 88 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 1 | 89 | | return warehouses |
| | 2 | 90 | | .Where(w => !string.IsNullOrWhiteSpace(w.Description)) |
| | 2 | 91 | | .OrderBy(w => w.Description, StringComparer.CurrentCulture) |
| | 1 | 92 | | .ToList(); |
| | | 93 | | } |
| | 0 | 94 | | catch (Exception ex) |
| | | 95 | | { |
| | 0 | 96 | | _logger.LogWarning(ex, "[OneCStock] Не удалось загрузить список складов"); |
| | 0 | 97 | | return []; |
| | | 98 | | } |
| | 1 | 99 | | } |
| | | 100 | | |
| | | 101 | | public async Task<OneCWarehouseStockResult> QueryAsync(OneCWarehouseStockQuery query, CancellationToken ct = default |
| | | 102 | | { |
| | 40 | 103 | | var warehouseName = await ResolveWarehouseNameAsync(query.WarehouseKey); |
| | | 104 | | |
| | 40 | 105 | | if (query.WarehouseKey == Guid.Empty) |
| | 1 | 106 | | return OneCWarehouseStockResult.Failure(query.WarehouseKey, warehouseName, "Не выбран склад."); |
| | | 107 | | |
| | | 108 | | try |
| | | 109 | | { |
| | 39 | 110 | | return await QueryCoreAsync(query, warehouseName, ct); |
| | | 111 | | } |
| | 0 | 112 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 113 | | { |
| | 0 | 114 | | throw; |
| | | 115 | | } |
| | 2 | 116 | | catch (Exception ex) |
| | | 117 | | { |
| | 2 | 118 | | _logger.LogWarning(ex, "[OneCStock] Ошибка выборки остатков склада {Warehouse}", warehouseName); |
| | 2 | 119 | | return OneCWarehouseStockResult.Failure(query.WarehouseKey, warehouseName, |
| | 2 | 120 | | $"1С не ответила: {ex.Message}"); |
| | | 121 | | } |
| | 40 | 122 | | } |
| | | 123 | | |
| | | 124 | | private async Task<OneCWarehouseStockResult> QueryCoreAsync( |
| | | 125 | | OneCWarehouseStockQuery query, |
| | | 126 | | string warehouseName, |
| | | 127 | | CancellationToken ct) |
| | | 128 | | { |
| | 39 | 129 | | var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, RussianTimeZoneHelper.Zone); |
| | | 130 | | |
| | 39 | 131 | | if (query.ForceRefresh) |
| | 1 | 132 | | DropWarehouseCaches(query.WarehouseKey); |
| | | 133 | | |
| | 39 | 134 | | var snapshot = await LoadSnapshotAsync(query.WarehouseKey, ct); |
| | 38 | 135 | | var warnings = new List<string>(); |
| | | 136 | | |
| | | 137 | | // Карта последних приходов нужна всегда: из неё берутся дата и документ в строках, |
| | | 138 | | // фильтр по сроку поступления, фильтр по типу документа/поставщику и сортировка по давности. |
| | | 139 | | // Без фильтра по документу её отсутствие — деградация, с фильтром — отказ: |
| | | 140 | | // иначе пустая таблица выглядела бы как «таких позиций нет». |
| | 38 | 141 | | var receiptsRequired = query.DocumentFilter != ReceiptDocumentFilter.All || query.SupplierKey.HasValue; |
| | 38 | 142 | | IReadOnlyDictionary<Guid, LastReceiptInfo>? warehouseReceipts = null; |
| | | 143 | | try |
| | | 144 | | { |
| | 38 | 145 | | warehouseReceipts = await LoadWarehouseLastReceiptsAsync(query.WarehouseKey, ct); |
| | 33 | 146 | | } |
| | 5 | 147 | | catch (Exception ex) when (!ct.IsCancellationRequested && !receiptsRequired) |
| | | 148 | | { |
| | 4 | 149 | | _logger.LogWarning(ex, "[OneCStock] Документы прихода не прочитаны, склад {Warehouse}", warehouseName); |
| | 4 | 150 | | warnings.Add("Даты последнего прихода не загружены: 1С не отдала документы."); |
| | 4 | 151 | | } |
| | | 152 | | |
| | | 153 | | // Фильтр по сроку поступления считается от даты последнего прихода. Обороты регистра — |
| | | 154 | | // запасной путь без документов: они учитывают любые приходы (возвраты, оприходования) |
| | | 155 | | // и не совпадают с колонкой «дней на складе». |
| | | 156 | | IReadOnlyList<WarehouseStockPosition> filtered; |
| | 37 | 157 | | if (query.Mode != StockAgeFilterMode.None && warehouseReceipts == null) |
| | | 158 | | { |
| | 3 | 159 | | IReadOnlySet<Guid>? receiptKeys = null; |
| | 3 | 160 | | var (from, to) = OneCWarehouseStockQueryBuilder.ResolveWindow( |
| | 3 | 161 | | query.Mode, query.OlderThanDays, query.ReceiptFrom, query.ReceiptTo, now); |
| | | 162 | | try |
| | | 163 | | { |
| | 3 | 164 | | receiptKeys = await LoadReceiptKeysAsync(query.WarehouseKey, from, to, ct); |
| | 2 | 165 | | warnings.Add("Фильтр по сроку поступления посчитан по оборотам регистра, а не по документам."); |
| | 2 | 166 | | } |
| | 1 | 167 | | catch (Exception ex) when (!ct.IsCancellationRequested) |
| | | 168 | | { |
| | | 169 | | // Остатки уже получены — отдаём их без фильтра по дате, это полезнее пустой страницы. |
| | 1 | 170 | | _logger.LogWarning(ex, "[OneCStock] Обороты за период не загружены, склад {Warehouse}", warehouseName); |
| | 1 | 171 | | warnings.Add("Фильтр по сроку поступления не применён: 1С не отдала обороты за период."); |
| | 1 | 172 | | } |
| | | 173 | | |
| | 3 | 174 | | filtered = OneCWarehouseStockQueryBuilder.ApplyFilters(snapshot.Positions, query, receiptKeys); |
| | 3 | 175 | | } |
| | | 176 | | else |
| | | 177 | | { |
| | 34 | 178 | | var byAge = warehouseReceipts == null |
| | 34 | 179 | | ? snapshot.Positions |
| | 34 | 180 | | : OneCWarehouseStockQueryBuilder.ApplyAgeFilter(snapshot.Positions, query, warehouseReceipts, now); |
| | 34 | 181 | | filtered = OneCWarehouseStockQueryBuilder.ApplyFilters(byAge, query, null); |
| | | 182 | | } |
| | | 183 | | |
| | | 184 | | // Фильтр по типу документа/поставщику смотрит на последний приход позиции на склад: |
| | | 185 | | // текущий остаток должен был прийти именно этим документом, а не «когда-то давно». |
| | 37 | 186 | | if (receiptsRequired) |
| | | 187 | | { |
| | 9 | 188 | | filtered = filtered |
| | 17 | 189 | | .Where(position => warehouseReceipts!.TryGetValue(position.NomenclatureKey, out var receipt) && |
| | 17 | 190 | | OneCWarehouseStockQueryBuilder.MatchesDocumentFilter( |
| | 17 | 191 | | receipt, query.DocumentFilter, query.SupplierKey)) |
| | 9 | 192 | | .ToList(); |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | // Сортировка по давности идёт по точной дате последнего прихода из карты склада. |
| | | 196 | | // Сетка корзин из регистра оборотов — запасной вариант, если документы не прочитались. |
| | 37 | 197 | | IReadOnlyDictionary<Guid, StockAgeBucket>? ageBuckets = null; |
| | 37 | 198 | | if (query.Sort == "age" && warehouseReceipts == null) |
| | | 199 | | { |
| | | 200 | | try |
| | | 201 | | { |
| | 1 | 202 | | ageBuckets = await LoadAgeGridAsync(query.WarehouseKey, now, ct); |
| | 1 | 203 | | warnings.Add("Сортировка по давности неточная: 1С не отдала документы прихода, использованы обороты."); |
| | 1 | 204 | | } |
| | 0 | 205 | | catch (Exception ex) when (!ct.IsCancellationRequested) |
| | | 206 | | { |
| | 0 | 207 | | _logger.LogWarning(ex, "[OneCStock] Сетка давности не построена, склад {Warehouse}", warehouseName); |
| | 0 | 208 | | warnings.Add("Сортировка по давности неточная: 1С не отдала обороты."); |
| | 0 | 209 | | } |
| | | 210 | | } |
| | | 211 | | |
| | 37 | 212 | | var sorted = OneCWarehouseStockQueryBuilder.ApplySorting( |
| | 37 | 213 | | filtered, query.Sort, query.Desc, ageBuckets, warehouseReceipts); |
| | | 214 | | |
| | 37 | 215 | | var totalCount = sorted.Count; |
| | 103 | 216 | | var totalQuantity = sorted.Sum(p => p.Quantity); |
| | 37 | 217 | | var pageItems = sorted |
| | 37 | 218 | | .Skip((query.Page - 1) * query.PageSize) |
| | 37 | 219 | | .Take(query.PageSize) |
| | 37 | 220 | | .ToList(); |
| | | 221 | | |
| | 37 | 222 | | var rows = await EnrichAsync(pageItems, warehouseReceipts, ageBuckets, now, ct); |
| | | 223 | | |
| | 37 | 224 | | return new OneCWarehouseStockResult( |
| | 37 | 225 | | query.WarehouseKey, |
| | 37 | 226 | | warehouseName, |
| | 37 | 227 | | rows, |
| | 37 | 228 | | totalCount, |
| | 37 | 229 | | snapshot.Positions.Count, |
| | 37 | 230 | | totalQuantity, |
| | 37 | 231 | | query.Page, |
| | 37 | 232 | | query.PageSize, |
| | 37 | 233 | | snapshot.LoadedAtUtc, |
| | 37 | 234 | | false, |
| | 37 | 235 | | warnings.Count > 0 ? string.Join(" ", warnings) : null); |
| | 37 | 236 | | } |
| | | 237 | | |
| | | 238 | | // ── Слой 1: снимок остатков склада ──────────────────────────────────────── |
| | | 239 | | |
| | | 240 | | private Task<WarehouseStockSnapshot> LoadSnapshotAsync(Guid warehouseKey, CancellationToken ct) |
| | 39 | 241 | | => GetOrLoadAsync(SnapshotKey(warehouseKey), SnapshotTtl, async () => |
| | 39 | 242 | | { |
| | 35 | 243 | | var rows = await _client.GetListAsync<ODataStockBalance>( |
| | 35 | 244 | | // Отбор по складу — параметром Condition: $filter по измерениям виртуальной |
| | 35 | 245 | | // таблицы 1С Fresh отвергает с HTTP 500 «Операция не разрешена в предложении "ГДЕ"». |
| | 35 | 246 | | OneCRegisterEndpoint.WithCondition( |
| | 35 | 247 | | BalanceEndpoint, $"СтруктурнаяЕдиница_Key eq guid'{warehouseKey}'"), |
| | 35 | 248 | | new Dictionary<string, string> |
| | 35 | 249 | | { |
| | 35 | 250 | | ["$select"] = "Номенклатура_Key,СтруктурнаяЕдиница_Key,КоличествоBalance", |
| | 35 | 251 | | // Обязательно: у регистров нет Ref_Key, поэтому автоматический $orderby |
| | 35 | 252 | | // в IOneCODataClient к ним не добавляется, а без сортировки строки |
| | 35 | 253 | | // могут выпасть между независимыми $skip-страницами. |
| | 35 | 254 | | ["$orderby"] = "Номенклатура_Key" |
| | 35 | 255 | | }, |
| | 35 | 256 | | ct); |
| | 39 | 257 | | |
| | 108 | 258 | | var keys = rows.Select(r => r.NomenclatureKey).Distinct().ToList(); |
| | 34 | 259 | | var cards = await _mapStore.FindByRefKeysAsync(keys, ct); |
| | 34 | 260 | | var positions = OneCWarehouseStockQueryBuilder.Aggregate(rows, cards); |
| | 39 | 261 | | |
| | 34 | 262 | | _logger.LogInformation( |
| | 34 | 263 | | "[OneCStock] Склад {Warehouse}: {Rows} строк регистра → {Positions} позиций", |
| | 34 | 264 | | warehouseKey, rows.Count, positions.Count); |
| | 39 | 265 | | |
| | 34 | 266 | | return new WarehouseStockSnapshot(warehouseKey, positions, DateTime.UtcNow); |
| | 73 | 267 | | }, ct); |
| | | 268 | | |
| | | 269 | | // ── Слой 2: обороты за окно ─────────────────────────────────────────────── |
| | | 270 | | |
| | | 271 | | private Task<IReadOnlySet<Guid>> LoadReceiptKeysAsync( |
| | | 272 | | Guid warehouseKey, DateTime from, DateTime to, CancellationToken ct) |
| | 3 | 273 | | => GetOrLoadAsync(TurnoverKey(warehouseKey, from, to), TurnoverTtl, |
| | 6 | 274 | | () => FetchReceiptKeysAsync(warehouseKey, from, to, ct), ct); |
| | | 275 | | |
| | | 276 | | private async Task<IReadOnlySet<Guid>> FetchReceiptKeysAsync( |
| | | 277 | | Guid warehouseKey, DateTime from, DateTime to, CancellationToken ct) |
| | | 278 | | { |
| | 8 | 279 | | var rows = await _client.GetListAsync<ODataStockTurnover>( |
| | 8 | 280 | | OneCRegisterEndpoint.WithCondition( |
| | 8 | 281 | | OneCWarehouseStockQueryBuilder.TurnoversEndpoint(from, to), |
| | 8 | 282 | | $"СтруктурнаяЕдиница_Key eq guid'{warehouseKey}'"), |
| | 8 | 283 | | new Dictionary<string, string> { ["$orderby"] = "Номенклатура_Key" }, |
| | 8 | 284 | | ct); |
| | | 285 | | |
| | | 286 | | // Условие «приход больше нуля» проверяем локально: 1С Fresh не принимает фильтры |
| | | 287 | | // по ресурсам виртуальных таблиц. |
| | 7 | 288 | | return rows |
| | 1 | 289 | | .Where(r => r.QuantityReceipt > 0) |
| | 1 | 290 | | .Select(r => r.NomenclatureKey) |
| | 7 | 291 | | .ToHashSet(); |
| | 7 | 292 | | } |
| | | 293 | | |
| | | 294 | | // ── Слой 2a: документы прихода и последний приход по складу ────────────── |
| | | 295 | | |
| | | 296 | | /// <summary> |
| | | 297 | | /// Проведённый документ прихода в компактном виде: для перемещения <see cref="WarehouseKey"/> — |
| | | 298 | | /// склад-получатель, поставщик есть только у накладной. |
| | | 299 | | /// </summary> |
| | 1149 | 300 | | private sealed record ReceiptDocument( |
| | 125 | 301 | | Guid RefKey, |
| | 125 | 302 | | DateTime Date, |
| | 125 | 303 | | string? Number, |
| | 1161 | 304 | | Guid WarehouseKey, |
| | 125 | 305 | | Guid? SupplierKey, |
| | 250 | 306 | | string Kind, |
| | 1274 | 307 | | Guid[] NomenclatureKeys); |
| | | 308 | | |
| | | 309 | | /// <summary> |
| | | 310 | | /// Последний проведённый приход каждой позиции на склад по всем приходным накладным склада |
| | | 311 | | /// и перемещениям, где склад — получатель. Строится в памяти из общего набора документов: |
| | | 312 | | /// фильтр по типу документа и поставщику применяется к этой карте, она же даёт точную дату |
| | | 313 | | /// для сортировки по давности и дату прихода в строках таблицы. |
| | | 314 | | /// </summary> |
| | | 315 | | private Task<IReadOnlyDictionary<Guid, LastReceiptInfo>> LoadWarehouseLastReceiptsAsync( |
| | | 316 | | Guid warehouseKey, |
| | | 317 | | CancellationToken ct) |
| | 38 | 318 | | => GetOrLoadAsync(WarehouseReceiptsKey(warehouseKey), DocumentsTtl, async () => |
| | 38 | 319 | | { |
| | 34 | 320 | | var documents = await LoadReceiptDocumentsAsync(ct); |
| | 29 | 321 | | var result = new Dictionary<Guid, LastReceiptInfo>(); |
| | 29 | 322 | | var receipts = 0; |
| | 29 | 323 | | var transfers = 0; |
| | 38 | 324 | | |
| | 38 | 325 | | // Набор упорядочен «накладные, затем перемещения», сравнение строгое — |
| | 38 | 326 | | // при равной дате с перемещением приоритет остаётся у накладной. |
| | 2380 | 327 | | foreach (var document in documents) |
| | 38 | 328 | | { |
| | 1161 | 329 | | if (document.WarehouseKey != warehouseKey) continue; |
| | 250 | 330 | | if (document.Kind == ReceiptKind) receipts++; else transfers++; |
| | 38 | 331 | | |
| | 125 | 332 | | var info = new LastReceiptInfo( |
| | 125 | 333 | | document.Date, document.Number, document.RefKey, document.Kind, document.SupplierKey); |
| | 540 | 334 | | foreach (var nomenclatureKey in document.NomenclatureKeys) |
| | 38 | 335 | | { |
| | 145 | 336 | | result.TryGetValue(nomenclatureKey, out var current); |
| | 145 | 337 | | result[nomenclatureKey] = Newer(current, info)!; |
| | 38 | 338 | | } |
| | 38 | 339 | | } |
| | 38 | 340 | | |
| | 29 | 341 | | _logger.LogInformation( |
| | 29 | 342 | | "[OneCStock] Склад {Warehouse}: {Receipts} накладных, {Transfers} перемещений → {Positions} позиций с пр |
| | 29 | 343 | | warehouseKey, receipts, transfers, result.Count); |
| | 38 | 344 | | |
| | 29 | 345 | | return (IReadOnlyDictionary<Guid, LastReceiptInfo>)result; |
| | 67 | 346 | | }, ct); |
| | | 347 | | |
| | | 348 | | /// <summary> |
| | | 349 | | /// Все проведённые накладные и перемещения базы со строками. Кэш общий для всех складов: |
| | | 350 | | /// отбор по складу в 1С не работает (см. описание класса), а обход всех документов — |
| | | 351 | | /// десятки страниц по 1000, поэтому повторять его на каждый склад нельзя. |
| | | 352 | | /// </summary> |
| | | 353 | | private Task<IReadOnlyList<ReceiptDocument>> LoadReceiptDocumentsAsync(CancellationToken ct) |
| | 34 | 354 | | => GetOrLoadAsync(DocumentsKey, DocumentsTtl, async () => |
| | 34 | 355 | | { |
| | 33 | 356 | | var receiptsTask = LoadAllPagesAsync<ODataReceiptDocHeader>( |
| | 33 | 357 | | ReceiptHeaderEndpoint, |
| | 33 | 358 | | "Ref_Key,Date,Number,Posted,DeletionMark,СтруктурнаяЕдиница_Key,Контрагент_Key,Запасы/Номенклатура_Key", |
| | 33 | 359 | | ct); |
| | 33 | 360 | | var transfersTask = LoadAllPagesAsync<ODataTransferDocHeader>( |
| | 33 | 361 | | TransferHeaderEndpoint, |
| | 33 | 362 | | "Ref_Key,Date,Number,Posted,DeletionMark,СтруктурнаяЕдиницаПолучатель_Key,Запасы/Номенклатура_Key", |
| | 33 | 363 | | ct); |
| | 33 | 364 | | await Task.WhenAll(receiptsTask, transfersTask); |
| | 34 | 365 | | |
| | 28 | 366 | | var documents = new List<ReceiptDocument>(); |
| | 2282 | 367 | | foreach (var header in await receiptsTask) |
| | 34 | 368 | | { |
| | 1113 | 369 | | if (ToDocument(header, ReceiptKind, header.WarehouseKey, header.Lines) is { } document) |
| | 1113 | 370 | | documents.Add(document); |
| | 34 | 371 | | } |
| | 34 | 372 | | |
| | 128 | 373 | | foreach (var header in await transfersTask) |
| | 34 | 374 | | { |
| | 34 | 375 | | // Для перемещения приходом является склад-получатель, а не склад-источник. |
| | 36 | 376 | | if (ToDocument(header, TransferKind, header.WarehouseRecipientKey, header.Lines) is { } document) |
| | 36 | 377 | | documents.Add(document); |
| | 34 | 378 | | } |
| | 34 | 379 | | |
| | 28 | 380 | | _logger.LogInformation( |
| | 28 | 381 | | "[OneCStock] Документы прихода загружены: {Receipts} накладных, {Transfers} перемещений", |
| | 28 | 382 | | (await receiptsTask).Count, (await transfersTask).Count); |
| | 34 | 383 | | |
| | 28 | 384 | | return (IReadOnlyList<ReceiptDocument>)documents; |
| | 62 | 385 | | }, ct); |
| | | 386 | | |
| | | 387 | | /// <summary> |
| | | 388 | | /// Читает коллекцию целиком волнами по <see cref="MaxConcurrentRequests"/> страниц. Порядок страниц |
| | | 389 | | /// фиксирует <c>$orderby=Ref_Key</c>; волна с неполной страницей — последняя. |
| | | 390 | | /// </summary> |
| | | 391 | | private async Task<List<T>> LoadAllPagesAsync<T>(string endpoint, string select, CancellationToken ct) |
| | | 392 | | where T : ODataDocHeader |
| | | 393 | | { |
| | 66 | 394 | | var queryParams = new Dictionary<string, string> |
| | 66 | 395 | | { |
| | 66 | 396 | | ["$filter"] = PostedDocumentsFilter, |
| | 66 | 397 | | ["$select"] = select, |
| | 66 | 398 | | ["$orderby"] = "Ref_Key" |
| | 66 | 399 | | }; |
| | | 400 | | |
| | 66 | 401 | | var result = new List<T>(); |
| | 66 | 402 | | for (var wave = 0; ; wave++) |
| | | 403 | | { |
| | 66 | 404 | | ct.ThrowIfCancellationRequested(); |
| | | 405 | | |
| | 66 | 406 | | var pages = await Task.WhenAll(Enumerable.Range(0, MaxConcurrentRequests).Select(i => |
| | 330 | 407 | | _client.GetPageAsync<T>( |
| | 330 | 408 | | endpoint, |
| | 330 | 409 | | top: DocumentPageSize, |
| | 330 | 410 | | skip: (wave * MaxConcurrentRequests + i) * DocumentPageSize, |
| | 330 | 411 | | queryParams: queryParams))); |
| | | 412 | | |
| | 56 | 413 | | var complete = false; |
| | 560 | 414 | | foreach (var page in pages) |
| | | 415 | | { |
| | 224 | 416 | | result.AddRange(page); |
| | 224 | 417 | | if (page.Count < DocumentPageSize) |
| | 223 | 418 | | complete = true; |
| | | 419 | | } |
| | | 420 | | |
| | 56 | 421 | | if (complete) |
| | 56 | 422 | | return result; |
| | | 423 | | } |
| | 56 | 424 | | } |
| | | 425 | | |
| | | 426 | | private static ReceiptDocument? ToDocument( |
| | | 427 | | ODataDocHeader header, |
| | | 428 | | string kind, |
| | | 429 | | Guid warehouseKey, |
| | | 430 | | List<ODataDocNomenclatureLine> lines) |
| | | 431 | | { |
| | | 432 | | // Фильтр Posted/DeletionMark уходит в 1С, но проверяем и здесь — на случай, |
| | | 433 | | // если булево условие тоже начнут игнорировать. |
| | 1149 | 434 | | if (!header.Posted || header.DeletionMark || warehouseKey == Guid.Empty) return null; |
| | 1149 | 435 | | if (string.IsNullOrEmpty(header.Date) || |
| | 1149 | 436 | | !DateTime.TryParse(header.Date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) |
| | | 437 | | { |
| | 0 | 438 | | return null; |
| | | 439 | | } |
| | | 440 | | |
| | 1149 | 441 | | var supplierKey = header is ODataReceiptDocHeader receipt && receipt.CounterpartyKey != Guid.Empty |
| | 1149 | 442 | | ? receipt.CounterpartyKey |
| | 1149 | 443 | | : (Guid?)null; |
| | 1149 | 444 | | var nomenclatureKeys = lines |
| | 1169 | 445 | | .Select(line => line.NomenclatureKey) |
| | 1169 | 446 | | .Where(key => key != Guid.Empty) |
| | 1149 | 447 | | .Distinct() |
| | 1149 | 448 | | .ToArray(); |
| | | 449 | | |
| | 1149 | 450 | | return new ReceiptDocument(header.RefKey, date, header.Number, warehouseKey, supplierKey, kind, nomenclatureKeys |
| | | 451 | | } |
| | | 452 | | |
| | | 453 | | // ── Слой 3: сетка давности ──────────────────────────────────────────────── |
| | | 454 | | |
| | | 455 | | private Task<IReadOnlyDictionary<Guid, StockAgeBucket>> LoadAgeGridAsync( |
| | | 456 | | Guid warehouseKey, DateTime now, CancellationToken ct) |
| | 1 | 457 | | => GetOrLoadAsync(AgeGridKey(warehouseKey), AgeGridTtl, async () => |
| | 1 | 458 | | { |
| | 1 | 459 | | var grid = OneCWarehouseStockQueryBuilder.BuildAgeGrid(now); |
| | 1 | 460 | | |
| | 1 | 461 | | // Окна не пересекаются, поэтому идут параллельно и каждое остаётся лёгким. |
| | 1 | 462 | | var tasks = grid |
| | 5 | 463 | | .Select(w => FetchReceiptKeysAsync(warehouseKey, w.From, w.To, ct)) |
| | 1 | 464 | | .ToArray(); |
| | 1 | 465 | | var sets = await Task.WhenAll(tasks); |
| | 1 | 466 | | |
| | 1 | 467 | | var windows = grid |
| | 5 | 468 | | .Select((w, i) => (w.Bucket, (IReadOnlySet<Guid>)sets[i])) |
| | 1 | 469 | | .ToList(); |
| | 1 | 470 | | |
| | 1 | 471 | | var buckets = new Dictionary<Guid, StockAgeBucket>(); |
| | 12 | 472 | | foreach (var (_, keys) in windows) |
| | 1 | 473 | | { |
| | 10 | 474 | | foreach (var key in keys) |
| | 1 | 475 | | { |
| | 0 | 476 | | if (!buckets.ContainsKey(key)) |
| | 0 | 477 | | buckets[key] = OneCWarehouseStockQueryBuilder.ResolveBucket(key, windows); |
| | 1 | 478 | | } |
| | 1 | 479 | | } |
| | 1 | 480 | | |
| | 1 | 481 | | return (IReadOnlyDictionary<Guid, StockAgeBucket>)buckets; |
| | 2 | 482 | | }, ct); |
| | | 483 | | |
| | | 484 | | // ── Слой 4: построчное обогащение страницы ──────────────────────────────── |
| | | 485 | | |
| | | 486 | | private async Task<IReadOnlyList<OneCWarehouseStockRow>> EnrichAsync( |
| | | 487 | | IReadOnlyList<WarehouseStockPosition> positions, |
| | | 488 | | IReadOnlyDictionary<Guid, LastReceiptInfo>? receipts, |
| | | 489 | | IReadOnlyDictionary<Guid, StockAgeBucket>? ageBuckets, |
| | | 490 | | DateTime now, |
| | | 491 | | CancellationToken ct) |
| | | 492 | | { |
| | 37 | 493 | | if (positions.Count == 0) |
| | 1 | 494 | | return []; |
| | | 495 | | |
| | 100 | 496 | | var keys = positions.Select(p => p.NomenclatureKey).ToList(); |
| | 36 | 497 | | var cards = await LoadCardsAsync(keys, ct); |
| | 36 | 498 | | receipts ??= new Dictionary<Guid, LastReceiptInfo>(); |
| | 36 | 499 | | var supplierNames = await LoadCounterpartyNamesAsync( |
| | 64 | 500 | | keys.Select(key => receipts.TryGetValue(key, out var receipt) ? receipt.SupplierKey : null), |
| | 36 | 501 | | ct); |
| | | 502 | | |
| | 36 | 503 | | var rows = new List<OneCWarehouseStockRow>(positions.Count); |
| | 200 | 504 | | foreach (var position in positions) |
| | | 505 | | { |
| | 64 | 506 | | cards.TryGetValue(position.NomenclatureKey, out var card); |
| | 64 | 507 | | receipts.TryGetValue(position.NomenclatureKey, out var receipt); |
| | 64 | 508 | | var days = receipt is null ? (int?)null : OneCWarehouseStockQueryBuilder.DaysOnStock(receipt.Date, now); |
| | | 509 | | // При известной дате корзина считается от неё же — иначе подпись расходится с числом дней. |
| | 64 | 510 | | StockAgeBucket? bucket = days.HasValue |
| | 64 | 511 | | ? OneCWarehouseStockQueryBuilder.BucketFromDays(days.Value) |
| | 64 | 512 | | : ageBuckets != null && ageBuckets.TryGetValue(position.NomenclatureKey, out var b) |
| | 64 | 513 | | ? b |
| | 64 | 514 | | : null; |
| | 64 | 515 | | var supplierName = receipt?.SupplierKey is { } supplierKey && |
| | 64 | 516 | | supplierNames.TryGetValue(supplierKey, out var name) |
| | 64 | 517 | | ? name |
| | 64 | 518 | | : null; |
| | | 519 | | |
| | 64 | 520 | | rows.Add(new OneCWarehouseStockRow( |
| | 64 | 521 | | position.NomenclatureKey, |
| | 64 | 522 | | card?.Article, |
| | 64 | 523 | | card?.Description, |
| | 64 | 524 | | position.Producer, |
| | 64 | 525 | | position.Quantity, |
| | 64 | 526 | | receipt?.Date, |
| | 64 | 527 | | days, |
| | 64 | 528 | | receipt?.Number, |
| | 64 | 529 | | receipt?.Kind, |
| | 64 | 530 | | bucket, |
| | 64 | 531 | | supplierName)); |
| | | 532 | | } |
| | | 533 | | |
| | 36 | 534 | | return rows; |
| | 37 | 535 | | } |
| | | 536 | | |
| | | 537 | | /// <summary> |
| | | 538 | | /// Имена поставщиков для строк текущей страницы. Справочник контрагентов уже кэшируется |
| | | 539 | | /// reference-сервисом и используется для dropdown на странице, поэтому новых запросов |
| | | 540 | | /// при обычной загрузке таблицы не создаёт. |
| | | 541 | | /// </summary> |
| | | 542 | | private async Task<IReadOnlyDictionary<Guid, string>> LoadCounterpartyNamesAsync( |
| | | 543 | | IEnumerable<Guid?> counterpartyKeys, |
| | | 544 | | CancellationToken ct) |
| | | 545 | | { |
| | 36 | 546 | | var keys = counterpartyKeys |
| | 64 | 547 | | .Where(key => key.HasValue && key.Value != Guid.Empty) |
| | 2 | 548 | | .Select(key => key!.Value) |
| | 36 | 549 | | .ToHashSet(); |
| | 36 | 550 | | if (keys.Count == 0) |
| | 34 | 551 | | return new Dictionary<Guid, string>(); |
| | | 552 | | |
| | 2 | 553 | | var counterparties = await _referenceService.GetCounterpartiesAsync(); |
| | 2 | 554 | | return counterparties |
| | 1 | 555 | | .Where(counterparty => keys.Contains(counterparty.RefKey) && |
| | 1 | 556 | | !string.IsNullOrWhiteSpace(counterparty.Description)) |
| | 1 | 557 | | .GroupBy(counterparty => counterparty.RefKey) |
| | 4 | 558 | | .ToDictionary(group => group.Key, group => group.First().Description!); |
| | 36 | 559 | | } |
| | | 560 | | |
| | | 561 | | /// <summary> |
| | | 562 | | /// Наименование и артикул карточек — по одной, адресом <c>Catalog_Номенклатура(guid'…')</c>: |
| | | 563 | | /// пакетный <c>$filter=Ref_Key eq … or …</c> 1С Fresh отвергает. Кэшируются поштучно — |
| | | 564 | | /// соседние страницы их переиспользуют. |
| | | 565 | | /// </summary> |
| | | 566 | | private async Task<Dictionary<Guid, ODataNomenclature>> LoadCardsAsync( |
| | | 567 | | IReadOnlyList<Guid> keys, CancellationToken ct) |
| | | 568 | | { |
| | 36 | 569 | | var result = new Dictionary<Guid, ODataNomenclature>(keys.Count); |
| | 36 | 570 | | var missing = new List<Guid>(); |
| | | 571 | | |
| | 200 | 572 | | foreach (var key in keys.Distinct()) |
| | | 573 | | { |
| | 64 | 574 | | if (_cache.TryGetValue(NomenclatureKey(key), out ODataNomenclature? cached) && cached != null) |
| | 10 | 575 | | result[key] = cached; |
| | | 576 | | else |
| | 54 | 577 | | missing.Add(key); |
| | | 578 | | } |
| | | 579 | | |
| | 36 | 580 | | if (missing.Count == 0) |
| | 4 | 581 | | return result; |
| | | 582 | | |
| | 32 | 583 | | using var gate = new SemaphoreSlim(MaxConcurrentRequests); |
| | 32 | 584 | | var loaded = new ConcurrentBag<(Guid Key, ODataNomenclature Card)>(); |
| | | 585 | | |
| | 32 | 586 | | await Task.WhenAll(missing.Select(async key => |
| | 32 | 587 | | { |
| | 54 | 588 | | await gate.WaitAsync(ct); |
| | 32 | 589 | | try |
| | 32 | 590 | | { |
| | 54 | 591 | | var card = await _client.GetByGuidAsync<ODataNomenclature>( |
| | 54 | 592 | | NomenclatureEndpoint, key, "Ref_Key,Description,Артикул"); |
| | 54 | 593 | | if (card != null) |
| | 54 | 594 | | loaded.Add((key, card)); |
| | 54 | 595 | | } |
| | 0 | 596 | | catch (Exception ex) when (!ct.IsCancellationRequested) |
| | 32 | 597 | | { |
| | 0 | 598 | | _logger.LogWarning(ex, "[OneCStock] Не загружена карточка {Key}", key); |
| | 0 | 599 | | } |
| | 32 | 600 | | finally |
| | 32 | 601 | | { |
| | 54 | 602 | | gate.Release(); |
| | 32 | 603 | | } |
| | 86 | 604 | | })); |
| | | 605 | | |
| | 172 | 606 | | foreach (var (key, card) in loaded) |
| | | 607 | | { |
| | 54 | 608 | | _cache.Set(NomenclatureKey(key), card, NomenclatureTtl); |
| | 54 | 609 | | result[key] = card; |
| | | 610 | | } |
| | | 611 | | |
| | 32 | 612 | | return result; |
| | 36 | 613 | | } |
| | | 614 | | |
| | | 615 | | private static LastReceiptInfo? Newer(LastReceiptInfo? current, LastReceiptInfo? candidate) |
| | | 616 | | { |
| | 145 | 617 | | if (candidate == null) return current; |
| | 145 | 618 | | return current == null || candidate.Date > current.Date ? candidate : current; |
| | | 619 | | } |
| | | 620 | | |
| | | 621 | | // ── Инфраструктура кэша ─────────────────────────────────────────────────── |
| | | 622 | | |
| | | 623 | | /// <summary> |
| | | 624 | | /// Читает из кэша, а при промахе загружает под семафором на ключ: параллельные открытия |
| | | 625 | | /// одного склада не должны превращаться в несколько тяжёлых обходов регистра. |
| | | 626 | | /// </summary> |
| | | 627 | | private async Task<T> GetOrLoadAsync<T>(string key, TimeSpan ttl, Func<Task<T>> factory, CancellationToken ct) |
| | | 628 | | { |
| | 115 | 629 | | if (_cache.TryGetValue(key, out T? cached) && cached != null) |
| | 9 | 630 | | return cached; |
| | | 631 | | |
| | 209 | 632 | | var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); |
| | 106 | 633 | | await gate.WaitAsync(ct); |
| | | 634 | | try |
| | | 635 | | { |
| | 106 | 636 | | if (_cache.TryGetValue(key, out cached) && cached != null) |
| | 0 | 637 | | return cached; |
| | | 638 | | |
| | 106 | 639 | | var value = await factory(); |
| | 94 | 640 | | _cache.Set(key, value, ttl); |
| | 94 | 641 | | return value; |
| | | 642 | | } |
| | | 643 | | finally |
| | | 644 | | { |
| | 106 | 645 | | gate.Release(); |
| | | 646 | | } |
| | 103 | 647 | | } |
| | | 648 | | |
| | | 649 | | private void DropWarehouseCaches(Guid warehouseKey) |
| | | 650 | | { |
| | 1 | 651 | | _cache.Remove(SnapshotKey(warehouseKey)); |
| | 1 | 652 | | _cache.Remove(AgeGridKey(warehouseKey)); |
| | 1 | 653 | | _cache.Remove(WarehouseReceiptsKey(warehouseKey)); |
| | | 654 | | // «Обновить из 1С» перечитывает и документы: новая накладная должна появиться в датах прихода. |
| | 1 | 655 | | _cache.Remove(DocumentsKey); |
| | | 656 | | // Ключи оборотов зависят от окна фильтра и живут 15 минут — точечно их не чистим, |
| | | 657 | | // обороты за то же окно меняются редко. |
| | 1 | 658 | | } |
| | | 659 | | |
| | | 660 | | private async Task<string> ResolveWarehouseNameAsync(Guid warehouseKey) |
| | | 661 | | { |
| | | 662 | | try |
| | | 663 | | { |
| | 40 | 664 | | var warehouses = await _referenceService.GetWarehousesAsync(); |
| | 82 | 665 | | return warehouses.FirstOrDefault(w => w.RefKey == warehouseKey)?.Description ?? warehouseKey.ToString(); |
| | | 666 | | } |
| | 0 | 667 | | catch |
| | | 668 | | { |
| | 0 | 669 | | return warehouseKey.ToString(); |
| | | 670 | | } |
| | 40 | 671 | | } |
| | | 672 | | |
| | | 673 | | private const string DocumentsKey = "onec_whstock_docs"; |
| | 40 | 674 | | private static string SnapshotKey(Guid warehouseKey) => $"onec_whstock_snapshot_{warehouseKey}"; |
| | 2 | 675 | | private static string AgeGridKey(Guid warehouseKey) => $"onec_whstock_agegrid_{warehouseKey}"; |
| | 118 | 676 | | private static string NomenclatureKey(Guid nomenclatureKey) => $"onec_whstock_nom_{nomenclatureKey}"; |
| | 39 | 677 | | private static string WarehouseReceiptsKey(Guid warehouseKey) => $"onec_whstock_whrcv_{warehouseKey}"; |
| | | 678 | | |
| | | 679 | | private static string TurnoverKey(Guid warehouseKey, DateTime from, DateTime to) |
| | 3 | 680 | | => $"onec_whstock_recv_{warehouseKey}_{from:yyyyMMdd}_{to:yyyyMMdd}"; |
| | | 681 | | } |