< Summary

Information
Class: Services.ApiServices._1CApi.Stock.OneCWarehouseStockQueryBuilder
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/Stock/OneCWarehouseStockQueryBuilder.cs
Line coverage
88%
Covered lines: 101
Uncovered lines: 13
Coverable lines: 114
Total lines: 277
Line coverage: 88.5%
Branch coverage
76%
Covered branches: 93
Total branches: 122
Branch coverage: 76.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Aggregate(...)100%1010100%
ApplyAgeFilter(...)100%1010100%
ApplyFilters(...)83.33%181894.44%
BucketOf()100%44100%
ReceiptDateOf()100%44100%
ApplySorting(...)50%312680%
ResolveBucket(...)100%44100%
BuildOrFilter(...)100%11100%
ResolveWindow(...)70%1010100%
MatchesDocumentFilter(...)92.85%1414100%
DaysOnStock(...)100%22100%
TurnoversEndpoint(...)100%11100%
BuildAgeGrid(...)100%22100%
NormalizeArticle(...)100%22100%
BucketFromDays(...)60%141066.66%
BucketLabel(...)16.66%12644.44%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Services.ApiServices._1CApi.OData;
 4using Services.ApiServices._1CApi.OData.Models;
 5
 6namespace Services.ApiServices._1CApi.Stock;
 7
 8/// <summary>
 9/// Чистая логика раздела остатков 1С: агрегация, фильтры, сортировка, сборка OData-фрагментов.
 10/// Вынесена из сервиса, чтобы покрываться тестами без обращений к 1С.
 11/// </summary>
 12public static class OneCWarehouseStockQueryBuilder
 13{
 14    /// <summary>Границы окон давности, от свежего к старому.</summary>
 115    public static readonly int[] AgeGridDays = [30, 90, 180, 365, 730];
 16
 17    private const string TurnoversTemplate =
 18        "AccumulationRegister_ЗапасыНаСкладах/Turnovers(StartPeriod=datetime'{0:yyyy-MM-ddTHH:mm:ss}',EndPeriod=datetime
 19
 20    /// <summary>
 21    /// Схлопывает строки регистра в позиции номенклатуры. Одна номенклатура может дать несколько
 22    /// строк Balance — регистр ведётся ещё и по организации, партии, характеристике и ячейке,
 23    /// поэтому количество строк не равно количеству позиций.
 24    /// </summary>
 25    public static IReadOnlyList<WarehouseStockPosition> Aggregate(
 26        IEnumerable<ODataStockBalance> rows,
 27        IReadOnlyDictionary<Guid, OneCMapEntry> cards)
 28    {
 3829        var totals = new Dictionary<Guid, decimal>();
 24230        foreach (var row in rows)
 31        {
 8332            totals.TryGetValue(row.NomenclatureKey, out var existing);
 8333            totals[row.NomenclatureKey] = existing + row.QuantityBalance;
 34        }
 35
 3836        var result = new List<WarehouseStockPosition>(totals.Count);
 23637        foreach (var (key, quantity) in totals)
 38        {
 39            // Нулевые и отрицательные остатки в разделе не показываем: виртуальная таблица их
 40            // обычно и не отдаёт, но взаимозачёт строк по партиям может дать ноль.
 8041            if (quantity <= 0)
 42                continue;
 43
 7744            cards.TryGetValue(key, out var card);
 7745            result.Add(new WarehouseStockPosition(key, quantity, card?.ArticleNorm, card?.ProducerName));
 46        }
 47
 3848        return result;
 49    }
 50
 51    /// <summary>
 52    /// Фильтр по сроку поступления по точной дате последнего прихода (накладная или перемещение).
 53    /// «Лежит дольше N дней» — последний приход раньше <c>сегодня − N</c>; позиции без документа
 54    /// прихода считаются самыми старыми и проходят. «Приход в диапазоне» — последний приход внутри окна.
 55    /// Срок совпадает с колонкой «дней на складе»: «дольше 30» ⇔ в колонке больше 30.
 56    /// </summary>
 57    public static IReadOnlyList<WarehouseStockPosition> ApplyAgeFilter(
 58        IReadOnlyList<WarehouseStockPosition> positions,
 59        OneCWarehouseStockQuery query,
 60        IReadOnlyDictionary<Guid, LastReceiptInfo> lastReceipts,
 61        DateTime now)
 62    {
 3663        if (query.Mode == StockAgeFilterMode.None)
 3264            return positions;
 65
 466        var (from, to) = ResolveWindow(query.Mode, query.OlderThanDays, query.ReceiptFrom, query.ReceiptTo, now);
 467        IEnumerable<WarehouseStockPosition> filtered = query.Mode switch
 468        {
 269            StockAgeFilterMode.OlderThanDays => positions.Where(p =>
 870                !lastReceipts.TryGetValue(p.NomenclatureKey, out var receipt) || receipt.Date < from),
 271            _ => positions.Where(p =>
 872                lastReceipts.TryGetValue(p.NomenclatureKey, out var receipt) && receipt.Date >= from && receipt.Date <= 
 473        };
 74
 475        return filtered.ToList();
 76    }
 77
 78    /// <summary>
 79    /// Применяет фильтр по сроку поступления по оборотам регистра и текстовые фильтры.
 80    /// <paramref name="receiptKeysInWindow"/> — позиции, у которых был приход в окне фильтра;
 81    /// используется как запасной путь, когда документы прихода из 1С не прочитались.
 82    /// </summary>
 83    public static IReadOnlyList<WarehouseStockPosition> ApplyFilters(
 84        IReadOnlyList<WarehouseStockPosition> positions,
 85        OneCWarehouseStockQuery query,
 86        IReadOnlySet<Guid>? receiptKeysInWindow)
 87    {
 4388        IEnumerable<WarehouseStockPosition> filtered = positions;
 89
 4390        if (receiptKeysInWindow != null)
 91        {
 492            filtered = query.Mode switch
 493            {
 494                // «Лежит дольше N дней» — прихода в окне не было.
 1295                StockAgeFilterMode.OlderThanDays => filtered.Where(p => !receiptKeysInWindow.Contains(p.NomenclatureKey)
 496                StockAgeFilterMode.ReceiptBetween => filtered.Where(p => receiptKeysInWindow.Contains(p.NomenclatureKey)
 097                _ => filtered
 498            };
 99        }
 100
 43101        if (query.MinQuantity > 0)
 4102            filtered = filtered.Where(p => p.Quantity >= query.MinQuantity);
 103
 43104        var article = NormalizeArticle(query.ArticleSearch);
 43105        if (!string.IsNullOrEmpty(article))
 4106            filtered = filtered.Where(p => p.ArticleNorm != null && p.ArticleNorm.Contains(article, StringComparison.Ord
 107
 43108        var producer = query.ProducerSearch?.Trim();
 43109        if (!string.IsNullOrEmpty(producer))
 4110            filtered = filtered.Where(p => p.Producer != null && p.Producer.Contains(producer, StringComparison.OrdinalI
 111
 43112        return filtered.ToList();
 113    }
 114
 115    /// <summary>
 116    /// Сортировка набора. Порядок стабилен: при равных значениях позиции упорядочены по GUID.
 117    /// Давность считается по точной дате из <paramref name="lastReceipts"/>; если карты нет —
 118    /// по корзинам оборотов. Позиции без прихода считаются самыми старыми.
 119    /// </summary>
 120    public static IReadOnlyList<WarehouseStockPosition> ApplySorting(
 121        IReadOnlyList<WarehouseStockPosition> positions,
 122        string sort,
 123        bool desc,
 124        IReadOnlyDictionary<Guid, StockAgeBucket>? ageBuckets,
 125        IReadOnlyDictionary<Guid, LastReceiptInfo>? lastReceipts = null)
 126    {
 127        StockAgeBucket BucketOf(WarehouseStockPosition p)
 9128            => ageBuckets != null && ageBuckets.TryGetValue(p.NomenclatureKey, out var bucket)
 9129                ? bucket
 9130                : StockAgeBucket.Over730;
 131
 132        DateTime ReceiptDateOf(WarehouseStockPosition p)
 9133            => lastReceipts != null && lastReceipts.TryGetValue(p.NomenclatureKey, out var receipt)
 9134                ? receipt.Date
 9135                : DateTime.MinValue;
 136
 42137        IOrderedEnumerable<WarehouseStockPosition> ordered = sort switch
 42138        {
 1139            "qty" => desc
 3140                ? positions.OrderByDescending(p => p.Quantity)
 1141                : positions.OrderBy(p => p.Quantity),
 0142            "producer" => desc
 0143                ? positions.OrderByDescending(p => p.Producer ?? string.Empty, StringComparer.OrdinalIgnoreCase)
 0144                : positions.OrderBy(p => p.Producer ?? string.Empty, StringComparer.OrdinalIgnoreCase),
 42145            // «Дней на складе» по убыванию — это самые старые (ранняя дата) первыми.
 9146            "age" when lastReceipts != null => desc
 3147                ? positions.OrderBy(ReceiptDateOf)
 3148                : positions.OrderByDescending(ReceiptDateOf),
 3149            "age" => desc
 3150                ? positions.OrderByDescending(BucketOf)
 3151                : positions.OrderBy(BucketOf),
 35152            _ => desc
 0153                ? positions.OrderByDescending(p => p.ArticleNorm ?? string.Empty, StringComparer.OrdinalIgnoreCase)
 95154                : positions.OrderBy(p => p.ArticleNorm ?? string.Empty, StringComparer.OrdinalIgnoreCase)
 42155        };
 156
 123157        return ordered.ThenBy(p => p.NomenclatureKey).ToList();
 158    }
 159
 160    /// <summary>
 161    /// Корзина давности по набору окон, упорядоченному от свежего к старому.
 162    /// Позиция попадает в первое окно, где у неё был приход.
 163    /// </summary>
 164    public static StockAgeBucket ResolveBucket(
 165        Guid nomenclatureKey,
 166        IReadOnlyList<(StockAgeBucket Bucket, IReadOnlySet<Guid> Keys)> windows)
 167    {
 12168        foreach (var (bucket, keys) in windows)
 169        {
 4170            if (keys.Contains(nomenclatureKey))
 2171                return bucket;
 172        }
 173
 1174        return StockAgeBucket.Over730;
 2175    }
 176
 177    /// <summary>Собирает OR-фильтр по GUID: «Поле eq guid'…' or Поле eq guid'…'».</summary>
 178    public static string BuildOrFilter(string field, IEnumerable<Guid> keys)
 4179        => string.Join(" or ", keys.Select(k => $"{field} eq guid'{k}'"));
 180
 181    /// <summary>
 182    /// Окно оборотов для фильтра. Для диапазона дат верхняя граница расширяется до конца суток,
 183    /// иначе приходы последнего дня выпадают из выборки.
 184    /// </summary>
 185    public static (DateTime From, DateTime To) ResolveWindow(
 186        StockAgeFilterMode mode,
 187        int? olderThanDays,
 188        DateTime? receiptFrom,
 189        DateTime? receiptTo,
 190        DateTime now)
 191    {
 10192        if (mode == StockAgeFilterMode.OlderThanDays)
 193        {
 6194            var days = olderThanDays is > 0 ? olderThanDays.Value : 90;
 6195            return (now.Date.AddDays(-days), now);
 196        }
 197
 4198        var to = (receiptTo ?? now).Date.AddDays(1).AddSeconds(-1);
 4199        var from = (receiptFrom ?? to.AddMonths(-1)).Date;
 4200        return from > to ? (to.Date, to) : (from, to);
 201    }
 202
 203    /// <summary>Сколько дней позиция лежит на складе с последнего прихода. Не бывает отрицательным.</summary>
 204    /// <summary>
 205    /// Подходит ли последний приход позиции под фильтр по типу документа и поставщику.
 206    /// Поставщик есть только у приходной накладной, поэтому при выбранном поставщике
 207    /// перемещения не проходят даже при фильтре «все документы».
 208    /// </summary>
 209    public static bool MatchesDocumentFilter(
 210        LastReceiptInfo receipt,
 211        ReceiptDocumentFilter documentFilter,
 212        Guid? supplierKey)
 213    {
 15214        var isReceipt = receipt.Kind == OneCWarehouseStockService.ReceiptKind;
 18215        if (documentFilter == ReceiptDocumentFilter.ReceiptInvoice && !isReceipt) return false;
 15216        if (documentFilter == ReceiptDocumentFilter.StockTransfer && isReceipt) return false;
 10217        if (supplierKey.HasValue && (!isReceipt || receipt.SupplierKey != supplierKey.Value)) return false;
 8218        return true;
 219    }
 220
 221    public static int DaysOnStock(DateTime lastReceipt, DateTime now)
 222    {
 28223        var days = (now.Date - lastReceipt.Date).Days;
 28224        return days < 0 ? 0 : days;
 225    }
 226
 227    /// <summary>Адрес виртуальной таблицы оборотов за период.</summary>
 228    public static string TurnoversEndpoint(DateTime from, DateTime to)
 9229        => string.Format(CultureInfo.InvariantCulture, TurnoversTemplate, from, to);
 230
 231    /// <summary>
 232    /// Непересекающиеся окна для сетки давности, от свежего к старому. Именно непересекающиеся:
 233    /// вложенные окна (30, 90, 180…) дают всё более тяжёлые запросы, а их сумма — то же разбиение.
 234    /// </summary>
 235    public static IReadOnlyList<(StockAgeBucket Bucket, DateTime From, DateTime To)> BuildAgeGrid(DateTime now)
 236    {
 2237        var grid = new List<(StockAgeBucket, DateTime, DateTime)>(AgeGridDays.Length);
 2238        var upperBound = now;
 239
 24240        for (var i = 0; i < AgeGridDays.Length; i++)
 241        {
 10242            var from = now.Date.AddDays(-AgeGridDays[i]);
 10243            grid.Add(((StockAgeBucket)i, from, upperBound));
 10244            upperBound = from.AddSeconds(-1);
 245        }
 246
 2247        return grid;
 248    }
 249
 250    /// <summary>Нормализация артикула — та же, что при поиске карточек: только буквы и цифры, верхний регистр.</summary
 251    public static string NormalizeArticle(string? article)
 46252        => string.IsNullOrWhiteSpace(article)
 46253            ? string.Empty
 46254            : Regex.Replace(article, "[^a-zA-Zа-яА-Я0-9]", "").ToUpperInvariant();
 255
 256    /// <summary>Человекочитаемая подпись корзины давности.</summary>
 257    /// <summary>Корзина давности по точному числу дней — те же границы, что у сетки оборотов.</summary>
 23258    public static StockAgeBucket BucketFromDays(int days) => days switch
 23259    {
 1260        <= 30 => StockAgeBucket.Upto30,
 6261        <= 90 => StockAgeBucket.Upto90,
 16262        <= 180 => StockAgeBucket.Upto180,
 0263        <= 365 => StockAgeBucket.Upto365,
 0264        <= 730 => StockAgeBucket.Upto730,
 0265        _ => StockAgeBucket.Over730
 23266    };
 267
 101268    public static string BucketLabel(StockAgeBucket bucket) => bucket switch
 101269    {
 101270        StockAgeBucket.Upto30 => "до 30 дней",
 0271        StockAgeBucket.Upto90 => "30–90 дней",
 0272        StockAgeBucket.Upto180 => "90–180 дней",
 0273        StockAgeBucket.Upto365 => "180–365 дней",
 0274        StockAgeBucket.Upto730 => "1–2 года",
 0275        _ => "больше 2 лет"
 101276    };
 277}

Methods/Properties

.cctor()
Aggregate(System.Collections.Generic.IEnumerable`1<Services.ApiServices._1CApi.OData.Models.ODataStockBalance>,System.Collections.Generic.IReadOnlyDictionary`2<System.Guid,Services.ApiServices._1CApi.OData.OneCMapEntry>)
ApplyAgeFilter(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Stock.WarehouseStockPosition>,Services.ApiServices._1CApi.Stock.OneCWarehouseStockQuery,System.Collections.Generic.IReadOnlyDictionary`2<System.Guid,Services.ApiServices._1CApi.Stock.LastReceiptInfo>,System.DateTime)
ApplyFilters(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Stock.WarehouseStockPosition>,Services.ApiServices._1CApi.Stock.OneCWarehouseStockQuery,System.Collections.Generic.IReadOnlySet`1<System.Guid>)
BucketOf()
ReceiptDateOf()
ApplySorting(System.Collections.Generic.IReadOnlyList`1<Services.ApiServices._1CApi.Stock.WarehouseStockPosition>,System.String,System.Boolean,System.Collections.Generic.IReadOnlyDictionary`2<System.Guid,Services.ApiServices._1CApi.Stock.StockAgeBucket>,System.Collections.Generic.IReadOnlyDictionary`2<System.Guid,Services.ApiServices._1CApi.Stock.LastReceiptInfo>)
ResolveBucket(System.Guid,System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<Services.ApiServices._1CApi.Stock.StockAgeBucket,System.Collections.Generic.IReadOnlySet`1<System.Guid>>>)
BuildOrFilter(System.String,System.Collections.Generic.IEnumerable`1<System.Guid>)
ResolveWindow(Services.ApiServices._1CApi.Stock.StockAgeFilterMode,System.Nullable`1<System.Int32>,System.Nullable`1<System.DateTime>,System.Nullable`1<System.DateTime>,System.DateTime)
MatchesDocumentFilter(Services.ApiServices._1CApi.Stock.LastReceiptInfo,Services.ApiServices._1CApi.Stock.ReceiptDocumentFilter,System.Nullable`1<System.Guid>)
DaysOnStock(System.DateTime,System.DateTime)
TurnoversEndpoint(System.DateTime,System.DateTime)
BuildAgeGrid(System.DateTime)
NormalizeArticle(System.String)
BucketFromDays(System.Int32)
BucketLabel(Services.ApiServices._1CApi.Stock.StockAgeBucket)