< Summary

Information
Class: Services.ApiServices._1CApi.Stock.OneCWarehouseStockService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Stock/OneCWarehouseStockService.cs
Line coverage
95%
Covered lines: 369
Uncovered lines: 18
Coverable lines: 387
Total lines: 681
Line coverage: 95.3%
Branch coverage
87%
Covered branches: 95
Total branches: 108
Branch coverage: 87.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetWarehousesAsync()100%1157.14%
QueryAsync()100%2281.81%
QueryCoreAsync()100%181893.65%
LoadSnapshotAsync(...)100%11100%
LoadReceiptKeysAsync(...)100%11100%
FetchReceiptKeysAsync()100%11100%
.ctor(...)100%11100%
get_RefKey()100%11100%
get_Date()100%11100%
get_Number()100%11100%
get_WarehouseKey()100%11100%
get_SupplierKey()100%11100%
get_Kind()100%11100%
get_NomenclatureKeys()100%11100%
LoadWarehouseLastReceiptsAsync(...)100%11100%
LoadReceiptDocumentsAsync(...)100%11100%
LoadAllPagesAsync()87.5%88100%
ToDocument(...)64.28%141492.3%
LoadAgeGridAsync(...)100%1192.3%
EnrichAsync()90%3030100%
LoadCounterpartyNamesAsync()100%22100%
LoadCardsAsync()100%1010100%
<LoadCardsAsync()100%2272.72%
Newer(...)83.33%66100%
GetOrLoadAsync()80%101090%
DropWarehouseCaches(...)100%11100%
ResolveWarehouseNameAsync()100%6450%
SnapshotKey(...)100%11100%
AgeGridKey(...)100%11100%
NomenclatureKey(...)100%11100%
WarehouseReceiptsKey(...)100%11100%
TurnoverKey(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Stock/OneCWarehouseStockService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using System.Globalization;
 3using Microsoft.Extensions.Caching.Memory;
 4using Microsoft.Extensions.Logging;
 5using Services.ApiServices._1CApi.OData;
 6using Services.ApiServices._1CApi.OData.Models;
 7using Services.BackgroundJobsServices;
 8
 9namespace Services.ApiServices._1CApi.Stock;
 10
 11public 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>
 37public 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
 156    private static readonly TimeSpan SnapshotTtl = TimeSpan.FromMinutes(10);
 157    private static readonly TimeSpan TurnoverTtl = TimeSpan.FromMinutes(15);
 158    private static readonly TimeSpan AgeGridTtl = TimeSpan.FromMinutes(30);
 59    /// <summary>Все проведённые накладные и перемещения базы — самый тяжёлый слой, живёт дольше остальных.</summary>
 160    private static readonly TimeSpan DocumentsTtl = TimeSpan.FromMinutes(30);
 161    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;
 3568    private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
 69
 3570    public OneCWarehouseStockService(
 3571        IOneCODataClient client,
 3572        IOneCODataReferenceService referenceService,
 3573        IOneCNomenclatureMapStore mapStore,
 3574        IMemoryCache cache,
 3575        ILogger<OneCWarehouseStockService> logger)
 76    {
 3577        _client = client;
 3578        _referenceService = referenceService;
 3579        _mapStore = mapStore;
 3580        _cache = cache;
 3581        _logger = logger;
 3582    }
 83
 84    public async Task<IReadOnlyList<ODataWarehouse>> GetWarehousesAsync(CancellationToken ct = default)
 85    {
 86        try
 87        {
 188            var warehouses = await _referenceService.GetWarehousesAsync();
 189            return warehouses
 290                .Where(w => !string.IsNullOrWhiteSpace(w.Description))
 291                .OrderBy(w => w.Description, StringComparer.CurrentCulture)
 192                .ToList();
 93        }
 094        catch (Exception ex)
 95        {
 096            _logger.LogWarning(ex, "[OneCStock] Не удалось загрузить список складов");
 097            return [];
 98        }
 199    }
 100
 101    public async Task<OneCWarehouseStockResult> QueryAsync(OneCWarehouseStockQuery query, CancellationToken ct = default
 102    {
 40103        var warehouseName = await ResolveWarehouseNameAsync(query.WarehouseKey);
 104
 40105        if (query.WarehouseKey == Guid.Empty)
 1106            return OneCWarehouseStockResult.Failure(query.WarehouseKey, warehouseName, "Не выбран склад.");
 107
 108        try
 109        {
 39110            return await QueryCoreAsync(query, warehouseName, ct);
 111        }
 0112        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 113        {
 0114            throw;
 115        }
 2116        catch (Exception ex)
 117        {
 2118            _logger.LogWarning(ex, "[OneCStock] Ошибка выборки остатков склада {Warehouse}", warehouseName);
 2119            return OneCWarehouseStockResult.Failure(query.WarehouseKey, warehouseName,
 2120                $"1С не ответила: {ex.Message}");
 121        }
 40122    }
 123
 124    private async Task<OneCWarehouseStockResult> QueryCoreAsync(
 125        OneCWarehouseStockQuery query,
 126        string warehouseName,
 127        CancellationToken ct)
 128    {
 39129        var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, RussianTimeZoneHelper.Zone);
 130
 39131        if (query.ForceRefresh)
 1132            DropWarehouseCaches(query.WarehouseKey);
 133
 39134        var snapshot = await LoadSnapshotAsync(query.WarehouseKey, ct);
 38135        var warnings = new List<string>();
 136
 137        // Карта последних приходов нужна всегда: из неё берутся дата и документ в строках,
 138        // фильтр по сроку поступления, фильтр по типу документа/поставщику и сортировка по давности.
 139        // Без фильтра по документу её отсутствие — деградация, с фильтром — отказ:
 140        // иначе пустая таблица выглядела бы как «таких позиций нет».
 38141        var receiptsRequired = query.DocumentFilter != ReceiptDocumentFilter.All || query.SupplierKey.HasValue;
 38142        IReadOnlyDictionary<Guid, LastReceiptInfo>? warehouseReceipts = null;
 143        try
 144        {
 38145            warehouseReceipts = await LoadWarehouseLastReceiptsAsync(query.WarehouseKey, ct);
 33146        }
 5147        catch (Exception ex) when (!ct.IsCancellationRequested && !receiptsRequired)
 148        {
 4149            _logger.LogWarning(ex, "[OneCStock] Документы прихода не прочитаны, склад {Warehouse}", warehouseName);
 4150            warnings.Add("Даты последнего прихода не загружены: 1С не отдала документы.");
 4151        }
 152
 153        // Фильтр по сроку поступления считается от даты последнего прихода. Обороты регистра —
 154        // запасной путь без документов: они учитывают любые приходы (возвраты, оприходования)
 155        // и не совпадают с колонкой «дней на складе».
 156        IReadOnlyList<WarehouseStockPosition> filtered;
 37157        if (query.Mode != StockAgeFilterMode.None && warehouseReceipts == null)
 158        {
 3159            IReadOnlySet<Guid>? receiptKeys = null;
 3160            var (from, to) = OneCWarehouseStockQueryBuilder.ResolveWindow(
 3161                query.Mode, query.OlderThanDays, query.ReceiptFrom, query.ReceiptTo, now);
 162            try
 163            {
 3164                receiptKeys = await LoadReceiptKeysAsync(query.WarehouseKey, from, to, ct);
 2165                warnings.Add("Фильтр по сроку поступления посчитан по оборотам регистра, а не по документам.");
 2166            }
 1167            catch (Exception ex) when (!ct.IsCancellationRequested)
 168            {
 169                // Остатки уже получены — отдаём их без фильтра по дате, это полезнее пустой страницы.
 1170                _logger.LogWarning(ex, "[OneCStock] Обороты за период не загружены, склад {Warehouse}", warehouseName);
 1171                warnings.Add("Фильтр по сроку поступления не применён: 1С не отдала обороты за период.");
 1172            }
 173
 3174            filtered = OneCWarehouseStockQueryBuilder.ApplyFilters(snapshot.Positions, query, receiptKeys);
 3175        }
 176        else
 177        {
 34178            var byAge = warehouseReceipts == null
 34179                ? snapshot.Positions
 34180                : OneCWarehouseStockQueryBuilder.ApplyAgeFilter(snapshot.Positions, query, warehouseReceipts, now);
 34181            filtered = OneCWarehouseStockQueryBuilder.ApplyFilters(byAge, query, null);
 182        }
 183
 184        // Фильтр по типу документа/поставщику смотрит на последний приход позиции на склад:
 185        // текущий остаток должен был прийти именно этим документом, а не «когда-то давно».
 37186        if (receiptsRequired)
 187        {
 9188            filtered = filtered
 17189                .Where(position => warehouseReceipts!.TryGetValue(position.NomenclatureKey, out var receipt) &&
 17190                                   OneCWarehouseStockQueryBuilder.MatchesDocumentFilter(
 17191                                       receipt, query.DocumentFilter, query.SupplierKey))
 9192                .ToList();
 193        }
 194
 195        // Сортировка по давности идёт по точной дате последнего прихода из карты склада.
 196        // Сетка корзин из регистра оборотов — запасной вариант, если документы не прочитались.
 37197        IReadOnlyDictionary<Guid, StockAgeBucket>? ageBuckets = null;
 37198        if (query.Sort == "age" && warehouseReceipts == null)
 199        {
 200            try
 201            {
 1202                ageBuckets = await LoadAgeGridAsync(query.WarehouseKey, now, ct);
 1203                warnings.Add("Сортировка по давности неточная: 1С не отдала документы прихода, использованы обороты.");
 1204            }
 0205            catch (Exception ex) when (!ct.IsCancellationRequested)
 206            {
 0207                _logger.LogWarning(ex, "[OneCStock] Сетка давности не построена, склад {Warehouse}", warehouseName);
 0208                warnings.Add("Сортировка по давности неточная: 1С не отдала обороты.");
 0209            }
 210        }
 211
 37212        var sorted = OneCWarehouseStockQueryBuilder.ApplySorting(
 37213            filtered, query.Sort, query.Desc, ageBuckets, warehouseReceipts);
 214
 37215        var totalCount = sorted.Count;
 103216        var totalQuantity = sorted.Sum(p => p.Quantity);
 37217        var pageItems = sorted
 37218            .Skip((query.Page - 1) * query.PageSize)
 37219            .Take(query.PageSize)
 37220            .ToList();
 221
 37222        var rows = await EnrichAsync(pageItems, warehouseReceipts, ageBuckets, now, ct);
 223
 37224        return new OneCWarehouseStockResult(
 37225            query.WarehouseKey,
 37226            warehouseName,
 37227            rows,
 37228            totalCount,
 37229            snapshot.Positions.Count,
 37230            totalQuantity,
 37231            query.Page,
 37232            query.PageSize,
 37233            snapshot.LoadedAtUtc,
 37234            false,
 37235            warnings.Count > 0 ? string.Join(" ", warnings) : null);
 37236    }
 237
 238    // ── Слой 1: снимок остатков склада ────────────────────────────────────────
 239
 240    private Task<WarehouseStockSnapshot> LoadSnapshotAsync(Guid warehouseKey, CancellationToken ct)
 39241        => GetOrLoadAsync(SnapshotKey(warehouseKey), SnapshotTtl, async () =>
 39242        {
 35243            var rows = await _client.GetListAsync<ODataStockBalance>(
 35244                // Отбор по складу — параметром Condition: $filter по измерениям виртуальной
 35245                // таблицы 1С Fresh отвергает с HTTP 500 «Операция не разрешена в предложении "ГДЕ"».
 35246                OneCRegisterEndpoint.WithCondition(
 35247                    BalanceEndpoint, $"СтруктурнаяЕдиница_Key eq guid'{warehouseKey}'"),
 35248                new Dictionary<string, string>
 35249                {
 35250                    ["$select"] = "Номенклатура_Key,СтруктурнаяЕдиница_Key,КоличествоBalance",
 35251                    // Обязательно: у регистров нет Ref_Key, поэтому автоматический $orderby
 35252                    // в IOneCODataClient к ним не добавляется, а без сортировки строки
 35253                    // могут выпасть между независимыми $skip-страницами.
 35254                    ["$orderby"] = "Номенклатура_Key"
 35255                },
 35256                ct);
 39257
 108258            var keys = rows.Select(r => r.NomenclatureKey).Distinct().ToList();
 34259            var cards = await _mapStore.FindByRefKeysAsync(keys, ct);
 34260            var positions = OneCWarehouseStockQueryBuilder.Aggregate(rows, cards);
 39261
 34262            _logger.LogInformation(
 34263                "[OneCStock] Склад {Warehouse}: {Rows} строк регистра → {Positions} позиций",
 34264                warehouseKey, rows.Count, positions.Count);
 39265
 34266            return new WarehouseStockSnapshot(warehouseKey, positions, DateTime.UtcNow);
 73267        }, ct);
 268
 269    // ── Слой 2: обороты за окно ───────────────────────────────────────────────
 270
 271    private Task<IReadOnlySet<Guid>> LoadReceiptKeysAsync(
 272        Guid warehouseKey, DateTime from, DateTime to, CancellationToken ct)
 3273        => GetOrLoadAsync(TurnoverKey(warehouseKey, from, to), TurnoverTtl,
 6274            () => FetchReceiptKeysAsync(warehouseKey, from, to, ct), ct);
 275
 276    private async Task<IReadOnlySet<Guid>> FetchReceiptKeysAsync(
 277        Guid warehouseKey, DateTime from, DateTime to, CancellationToken ct)
 278    {
 8279        var rows = await _client.GetListAsync<ODataStockTurnover>(
 8280            OneCRegisterEndpoint.WithCondition(
 8281                OneCWarehouseStockQueryBuilder.TurnoversEndpoint(from, to),
 8282                $"СтруктурнаяЕдиница_Key eq guid'{warehouseKey}'"),
 8283            new Dictionary<string, string> { ["$orderby"] = "Номенклатура_Key" },
 8284            ct);
 285
 286        // Условие «приход больше нуля» проверяем локально: 1С Fresh не принимает фильтры
 287        // по ресурсам виртуальных таблиц.
 7288        return rows
 1289            .Where(r => r.QuantityReceipt > 0)
 1290            .Select(r => r.NomenclatureKey)
 7291            .ToHashSet();
 7292    }
 293
 294    // ── Слой 2a: документы прихода и последний приход по складу ──────────────
 295
 296    /// <summary>
 297    /// Проведённый документ прихода в компактном виде: для перемещения <see cref="WarehouseKey"/> —
 298    /// склад-получатель, поставщик есть только у накладной.
 299    /// </summary>
 1149300    private sealed record ReceiptDocument(
 125301        Guid RefKey,
 125302        DateTime Date,
 125303        string? Number,
 1161304        Guid WarehouseKey,
 125305        Guid? SupplierKey,
 250306        string Kind,
 1274307        Guid[] NomenclatureKeys);
 308
 309    /// <summary>
 310    /// Последний проведённый приход каждой позиции на склад по всем приходным накладным склада
 311    /// и перемещениям, где склад — получатель. Строится в памяти из общего набора документов:
 312    /// фильтр по типу документа и поставщику применяется к этой карте, она же даёт точную дату
 313    /// для сортировки по давности и дату прихода в строках таблицы.
 314    /// </summary>
 315    private Task<IReadOnlyDictionary<Guid, LastReceiptInfo>> LoadWarehouseLastReceiptsAsync(
 316        Guid warehouseKey,
 317        CancellationToken ct)
 38318        => GetOrLoadAsync(WarehouseReceiptsKey(warehouseKey), DocumentsTtl, async () =>
 38319        {
 34320            var documents = await LoadReceiptDocumentsAsync(ct);
 29321            var result = new Dictionary<Guid, LastReceiptInfo>();
 29322            var receipts = 0;
 29323            var transfers = 0;
 38324
 38325            // Набор упорядочен «накладные, затем перемещения», сравнение строгое —
 38326            // при равной дате с перемещением приоритет остаётся у накладной.
 2380327            foreach (var document in documents)
 38328            {
 1161329                if (document.WarehouseKey != warehouseKey) continue;
 250330                if (document.Kind == ReceiptKind) receipts++; else transfers++;
 38331
 125332                var info = new LastReceiptInfo(
 125333                    document.Date, document.Number, document.RefKey, document.Kind, document.SupplierKey);
 540334                foreach (var nomenclatureKey in document.NomenclatureKeys)
 38335                {
 145336                    result.TryGetValue(nomenclatureKey, out var current);
 145337                    result[nomenclatureKey] = Newer(current, info)!;
 38338                }
 38339            }
 38340
 29341            _logger.LogInformation(
 29342                "[OneCStock] Склад {Warehouse}: {Receipts} накладных, {Transfers} перемещений → {Positions} позиций с пр
 29343                warehouseKey, receipts, transfers, result.Count);
 38344
 29345            return (IReadOnlyDictionary<Guid, LastReceiptInfo>)result;
 67346        }, ct);
 347
 348    /// <summary>
 349    /// Все проведённые накладные и перемещения базы со строками. Кэш общий для всех складов:
 350    /// отбор по складу в 1С не работает (см. описание класса), а обход всех документов —
 351    /// десятки страниц по 1000, поэтому повторять его на каждый склад нельзя.
 352    /// </summary>
 353    private Task<IReadOnlyList<ReceiptDocument>> LoadReceiptDocumentsAsync(CancellationToken ct)
 34354        => GetOrLoadAsync(DocumentsKey, DocumentsTtl, async () =>
 34355        {
 33356            var receiptsTask = LoadAllPagesAsync<ODataReceiptDocHeader>(
 33357                ReceiptHeaderEndpoint,
 33358                "Ref_Key,Date,Number,Posted,DeletionMark,СтруктурнаяЕдиница_Key,Контрагент_Key,Запасы/Номенклатура_Key",
 33359                ct);
 33360            var transfersTask = LoadAllPagesAsync<ODataTransferDocHeader>(
 33361                TransferHeaderEndpoint,
 33362                "Ref_Key,Date,Number,Posted,DeletionMark,СтруктурнаяЕдиницаПолучатель_Key,Запасы/Номенклатура_Key",
 33363                ct);
 33364            await Task.WhenAll(receiptsTask, transfersTask);
 34365
 28366            var documents = new List<ReceiptDocument>();
 2282367            foreach (var header in await receiptsTask)
 34368            {
 1113369                if (ToDocument(header, ReceiptKind, header.WarehouseKey, header.Lines) is { } document)
 1113370                    documents.Add(document);
 34371            }
 34372
 128373            foreach (var header in await transfersTask)
 34374            {
 34375                // Для перемещения приходом является склад-получатель, а не склад-источник.
 36376                if (ToDocument(header, TransferKind, header.WarehouseRecipientKey, header.Lines) is { } document)
 36377                    documents.Add(document);
 34378            }
 34379
 28380            _logger.LogInformation(
 28381                "[OneCStock] Документы прихода загружены: {Receipts} накладных, {Transfers} перемещений",
 28382                (await receiptsTask).Count, (await transfersTask).Count);
 34383
 28384            return (IReadOnlyList<ReceiptDocument>)documents;
 62385        }, 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    {
 66394        var queryParams = new Dictionary<string, string>
 66395        {
 66396            ["$filter"] = PostedDocumentsFilter,
 66397            ["$select"] = select,
 66398            ["$orderby"] = "Ref_Key"
 66399        };
 400
 66401        var result = new List<T>();
 66402        for (var wave = 0; ; wave++)
 403        {
 66404            ct.ThrowIfCancellationRequested();
 405
 66406            var pages = await Task.WhenAll(Enumerable.Range(0, MaxConcurrentRequests).Select(i =>
 330407                _client.GetPageAsync<T>(
 330408                    endpoint,
 330409                    top: DocumentPageSize,
 330410                    skip: (wave * MaxConcurrentRequests + i) * DocumentPageSize,
 330411                    queryParams: queryParams)));
 412
 56413            var complete = false;
 560414            foreach (var page in pages)
 415            {
 224416                result.AddRange(page);
 224417                if (page.Count < DocumentPageSize)
 223418                    complete = true;
 419            }
 420
 56421            if (complete)
 56422                return result;
 423        }
 56424    }
 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        // если булево условие тоже начнут игнорировать.
 1149434        if (!header.Posted || header.DeletionMark || warehouseKey == Guid.Empty) return null;
 1149435        if (string.IsNullOrEmpty(header.Date) ||
 1149436            !DateTime.TryParse(header.Date, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
 437        {
 0438            return null;
 439        }
 440
 1149441        var supplierKey = header is ODataReceiptDocHeader receipt && receipt.CounterpartyKey != Guid.Empty
 1149442            ? receipt.CounterpartyKey
 1149443            : (Guid?)null;
 1149444        var nomenclatureKeys = lines
 1169445            .Select(line => line.NomenclatureKey)
 1169446            .Where(key => key != Guid.Empty)
 1149447            .Distinct()
 1149448            .ToArray();
 449
 1149450        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)
 1457        => GetOrLoadAsync(AgeGridKey(warehouseKey), AgeGridTtl, async () =>
 1458        {
 1459            var grid = OneCWarehouseStockQueryBuilder.BuildAgeGrid(now);
 1460
 1461            // Окна не пересекаются, поэтому идут параллельно и каждое остаётся лёгким.
 1462            var tasks = grid
 5463                .Select(w => FetchReceiptKeysAsync(warehouseKey, w.From, w.To, ct))
 1464                .ToArray();
 1465            var sets = await Task.WhenAll(tasks);
 1466
 1467            var windows = grid
 5468                .Select((w, i) => (w.Bucket, (IReadOnlySet<Guid>)sets[i]))
 1469                .ToList();
 1470
 1471            var buckets = new Dictionary<Guid, StockAgeBucket>();
 12472            foreach (var (_, keys) in windows)
 1473            {
 10474                foreach (var key in keys)
 1475                {
 0476                    if (!buckets.ContainsKey(key))
 0477                        buckets[key] = OneCWarehouseStockQueryBuilder.ResolveBucket(key, windows);
 1478                }
 1479            }
 1480
 1481            return (IReadOnlyDictionary<Guid, StockAgeBucket>)buckets;
 2482        }, 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    {
 37493        if (positions.Count == 0)
 1494            return [];
 495
 100496        var keys = positions.Select(p => p.NomenclatureKey).ToList();
 36497        var cards = await LoadCardsAsync(keys, ct);
 36498        receipts ??= new Dictionary<Guid, LastReceiptInfo>();
 36499        var supplierNames = await LoadCounterpartyNamesAsync(
 64500            keys.Select(key => receipts.TryGetValue(key, out var receipt) ? receipt.SupplierKey : null),
 36501            ct);
 502
 36503        var rows = new List<OneCWarehouseStockRow>(positions.Count);
 200504        foreach (var position in positions)
 505        {
 64506            cards.TryGetValue(position.NomenclatureKey, out var card);
 64507            receipts.TryGetValue(position.NomenclatureKey, out var receipt);
 64508            var days = receipt is null ? (int?)null : OneCWarehouseStockQueryBuilder.DaysOnStock(receipt.Date, now);
 509            // При известной дате корзина считается от неё же — иначе подпись расходится с числом дней.
 64510            StockAgeBucket? bucket = days.HasValue
 64511                ? OneCWarehouseStockQueryBuilder.BucketFromDays(days.Value)
 64512                : ageBuckets != null && ageBuckets.TryGetValue(position.NomenclatureKey, out var b)
 64513                    ? b
 64514                    : null;
 64515            var supplierName = receipt?.SupplierKey is { } supplierKey &&
 64516                               supplierNames.TryGetValue(supplierKey, out var name)
 64517                ? name
 64518                : null;
 519
 64520            rows.Add(new OneCWarehouseStockRow(
 64521                position.NomenclatureKey,
 64522                card?.Article,
 64523                card?.Description,
 64524                position.Producer,
 64525                position.Quantity,
 64526                receipt?.Date,
 64527                days,
 64528                receipt?.Number,
 64529                receipt?.Kind,
 64530                bucket,
 64531                supplierName));
 532        }
 533
 36534        return rows;
 37535    }
 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    {
 36546        var keys = counterpartyKeys
 64547            .Where(key => key.HasValue && key.Value != Guid.Empty)
 2548            .Select(key => key!.Value)
 36549            .ToHashSet();
 36550        if (keys.Count == 0)
 34551            return new Dictionary<Guid, string>();
 552
 2553        var counterparties = await _referenceService.GetCounterpartiesAsync();
 2554        return counterparties
 1555            .Where(counterparty => keys.Contains(counterparty.RefKey) &&
 1556                                 !string.IsNullOrWhiteSpace(counterparty.Description))
 1557            .GroupBy(counterparty => counterparty.RefKey)
 4558            .ToDictionary(group => group.Key, group => group.First().Description!);
 36559    }
 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    {
 36569        var result = new Dictionary<Guid, ODataNomenclature>(keys.Count);
 36570        var missing = new List<Guid>();
 571
 200572        foreach (var key in keys.Distinct())
 573        {
 64574            if (_cache.TryGetValue(NomenclatureKey(key), out ODataNomenclature? cached) && cached != null)
 10575                result[key] = cached;
 576            else
 54577                missing.Add(key);
 578        }
 579
 36580        if (missing.Count == 0)
 4581            return result;
 582
 32583        using var gate = new SemaphoreSlim(MaxConcurrentRequests);
 32584        var loaded = new ConcurrentBag<(Guid Key, ODataNomenclature Card)>();
 585
 32586        await Task.WhenAll(missing.Select(async key =>
 32587        {
 54588            await gate.WaitAsync(ct);
 32589            try
 32590            {
 54591                var card = await _client.GetByGuidAsync<ODataNomenclature>(
 54592                    NomenclatureEndpoint, key, "Ref_Key,Description,Артикул");
 54593                if (card != null)
 54594                    loaded.Add((key, card));
 54595            }
 0596            catch (Exception ex) when (!ct.IsCancellationRequested)
 32597            {
 0598                _logger.LogWarning(ex, "[OneCStock] Не загружена карточка {Key}", key);
 0599            }
 32600            finally
 32601            {
 54602                gate.Release();
 32603            }
 86604        }));
 605
 172606        foreach (var (key, card) in loaded)
 607        {
 54608            _cache.Set(NomenclatureKey(key), card, NomenclatureTtl);
 54609            result[key] = card;
 610        }
 611
 32612        return result;
 36613    }
 614
 615    private static LastReceiptInfo? Newer(LastReceiptInfo? current, LastReceiptInfo? candidate)
 616    {
 145617        if (candidate == null) return current;
 145618        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    {
 115629        if (_cache.TryGetValue(key, out T? cached) && cached != null)
 9630            return cached;
 631
 209632        var gate = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
 106633        await gate.WaitAsync(ct);
 634        try
 635        {
 106636            if (_cache.TryGetValue(key, out cached) && cached != null)
 0637                return cached;
 638
 106639            var value = await factory();
 94640            _cache.Set(key, value, ttl);
 94641            return value;
 642        }
 643        finally
 644        {
 106645            gate.Release();
 646        }
 103647    }
 648
 649    private void DropWarehouseCaches(Guid warehouseKey)
 650    {
 1651        _cache.Remove(SnapshotKey(warehouseKey));
 1652        _cache.Remove(AgeGridKey(warehouseKey));
 1653        _cache.Remove(WarehouseReceiptsKey(warehouseKey));
 654        // «Обновить из 1С» перечитывает и документы: новая накладная должна появиться в датах прихода.
 1655        _cache.Remove(DocumentsKey);
 656        // Ключи оборотов зависят от окна фильтра и живут 15 минут — точечно их не чистим,
 657        // обороты за то же окно меняются редко.
 1658    }
 659
 660    private async Task<string> ResolveWarehouseNameAsync(Guid warehouseKey)
 661    {
 662        try
 663        {
 40664            var warehouses = await _referenceService.GetWarehousesAsync();
 82665            return warehouses.FirstOrDefault(w => w.RefKey == warehouseKey)?.Description ?? warehouseKey.ToString();
 666        }
 0667        catch
 668        {
 0669            return warehouseKey.ToString();
 670        }
 40671    }
 672
 673    private const string DocumentsKey = "onec_whstock_docs";
 40674    private static string SnapshotKey(Guid warehouseKey) => $"onec_whstock_snapshot_{warehouseKey}";
 2675    private static string AgeGridKey(Guid warehouseKey) => $"onec_whstock_agegrid_{warehouseKey}";
 118676    private static string NomenclatureKey(Guid nomenclatureKey) => $"onec_whstock_nom_{nomenclatureKey}";
 39677    private static string WarehouseReceiptsKey(Guid warehouseKey) => $"onec_whstock_whrcv_{warehouseKey}";
 678
 679    private static string TurnoverKey(Guid warehouseKey, DateTime from, DateTime to)
 3680        => $"onec_whstock_recv_{warehouseKey}_{from:yyyyMMdd}_{to:yyyyMMdd}";
 681}

Methods/Properties

.cctor()
.ctor(Services.ApiServices._1CApi.OData.IOneCODataClient,Services.ApiServices._1CApi.OData.IOneCODataReferenceService,Services.ApiServices._1CApi.OData.IOneCNomenclatureMapStore,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.Extensions.Logging.ILogger`1<Services.ApiServices._1CApi.Stock.OneCWarehouseStockService>)
GetWarehousesAsync()
QueryAsync()
QueryCoreAsync()
LoadSnapshotAsync(System.Guid,System.Threading.CancellationToken)
LoadReceiptKeysAsync(System.Guid,System.DateTime,System.DateTime,System.Threading.CancellationToken)
FetchReceiptKeysAsync()
.ctor(System.Guid,System.DateTime,System.String,System.Guid,System.Nullable`1<System.Guid>,System.String,System.Guid[])
get_RefKey()
get_Date()
get_Number()
get_WarehouseKey()
get_SupplierKey()
get_Kind()
get_NomenclatureKeys()
LoadWarehouseLastReceiptsAsync(System.Guid,System.Threading.CancellationToken)
LoadReceiptDocumentsAsync(System.Threading.CancellationToken)
LoadAllPagesAsync()
ToDocument(Services.ApiServices._1CApi.OData.Models.ODataDocHeader,System.String,System.Guid,System.Collections.Generic.List`1<Services.ApiServices._1CApi.OData.Models.ODataDocNomenclatureLine>)
LoadAgeGridAsync(System.Guid,System.DateTime,System.Threading.CancellationToken)
EnrichAsync()
LoadCounterpartyNamesAsync()
LoadCardsAsync()
<LoadCardsAsync()
Newer(Services.ApiServices._1CApi.Stock.LastReceiptInfo,Services.ApiServices._1CApi.Stock.LastReceiptInfo)
GetOrLoadAsync()
DropWarehouseCaches(System.Guid)
ResolveWarehouseNameAsync()
SnapshotKey(System.Guid)
AgeGridKey(System.Guid)
NomenclatureKey(System.Guid)
WarehouseReceiptsKey(System.Guid)
TurnoverKey(System.Guid,System.DateTime,System.DateTime)