< Summary

Information
Class: Services.BitrixStockServices.BitrixStockSyncReportGridSql
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/BitrixStockSyncReportGridSql.cs
Line coverage
80%
Covered lines: 124
Uncovered lines: 31
Coverable lines: 155
Total lines: 336
Line coverage: 80%
Branch coverage
65%
Covered branches: 67
Total branches: 102
Branch coverage: 65.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Sql()100%11100%
.cctor()100%11100%
IsSortable(...)50%22100%
IsFilterable(...)66.66%66100%
TryGetFacetExpression(...)0%2040%
get_Where()100%11100%
get_OrderBy()100%11100%
get_NeedsPriceJoin()100%11100%
get_Parameters()100%11100%
Build(...)92.5%4040100%
BuildCondition(...)80%101090.9%
BuildText(...)0%272160%
BuildNumber(...)70%282073.33%
Clip(...)75%44100%
EscapeLike(...)100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using System.Text;
 3
 4namespace Services.BitrixStockServices;
 5
 6/// <summary>
 7/// Построение SQL для грида строк отчёта синхронизации: whitelist колонок (colId → SQL-выражение), операторы
 8/// фильтров AG Grid, сортировки и быстрый поиск. Значения пользователя всегда уходят параметрами, в текст SQL
 9/// попадают только выражения из каталога. Класс чистый (без БД) — покрыт unit-тестами.
 10/// </summary>
 11public static class BitrixStockSyncReportGridSql
 12{
 13    public const int MaxBlockSize = 500;
 14    public const int MaxSorts = 5;
 15    public const int MaxConditions = 40;
 16    public const int MaxSetValues = 300;
 17    public const int MaxTextLength = 200;
 18    public const int MaxExcludedMappings = 300;
 19
 20    private const string PriceJson = "p.PriceDataJson";
 21
 22    /// <summary>Цена Ozon из снимка: новый формат {price:{price…}} и старый (сам price-объект), как в GetOzonPriceInfoA
 23    internal const string TruePriceSql =
 24        "CAST(COALESCE(JSON_VALUE(" + PriceJson + ", '$.price.price'), JSON_VALUE(" + PriceJson + ", '$.price.customer_p
 25        "JSON_VALUE(" + PriceJson + ", '$.price.true_price'), JSON_VALUE(" + PriceJson + ", '$.price'), " +
 26        "JSON_VALUE(" + PriceJson + ", '$.customer_price'), JSON_VALUE(" + PriceJson + ", '$.true_price')) AS DECIMAL(18
 27
 28    internal const string SellingPriceSql =
 29        "CAST(COALESCE(JSON_VALUE(" + PriceJson + ", '$.price.marketing_seller_price'), JSON_VALUE(" + PriceJson + ", '$
 30        "JSON_VALUE(" + PriceJson + ", '$.marketing_seller_price'), JSON_VALUE(" + PriceJson + ", '$.price')) AS DECIMAL
 31
 32    /// <summary>Индекс цены, сведённый к корзинам фильтра: BENEFIT/AVERAGE/OVERPRICED — синонимы GREEN/YELLOW/RED.</sum
 33    internal const string ColorIndexSql =
 34        "(CASE UPPER(COALESCE(JSON_VALUE(" + PriceJson + ", '$.price_indexes.color_index'), '')) " +
 35        "WHEN 'SUPER' THEN 'SUPER' WHEN 'GREEN' THEN 'GREEN' WHEN 'BENEFIT' THEN 'GREEN' " +
 36        "WHEN 'YELLOW' THEN 'YELLOW' WHEN 'AVERAGE' THEN 'YELLOW' WHEN 'RED' THEN 'RED' WHEN 'OVERPRICED' THEN 'RED' " +
 37        "ELSE 'NONE' END)";
 38
 39    /// <summary>Категория изменения C0 — та же формула, что getDeltaC0Category на клиенте (порог 5 %).</summary>
 40    internal const string DeltaC0Sql =
 41        "(CASE WHEN l.OneCPrice IS NULL OR l.PrevOneCPrice IS NULL THEN 'none' " +
 42        "WHEN ROUND(l.OneCPrice) = ROUND(l.PrevOneCPrice) THEN 'equal' " +
 43        "WHEN l.PrevOneCPrice <> 0 AND ABS((ROUND(l.OneCPrice) - ROUND(l.PrevOneCPrice)) / l.PrevOneCPrice) < 0.05 THEN 
 44        "WHEN ROUND(l.OneCPrice) > ROUND(l.PrevOneCPrice) THEN 'up' ELSE 'down' END)";
 45
 46    /// <summary>Целевой остаток («Нужно») — как getTargetQty на клиенте.</summary>
 47    internal const string TargetQtySql =
 48        "(CASE WHEN l.Action IN ('Clear','CleanupExtra','BatchVariantDisabledLink','BatchVariantZeroStock','BatchVariant
 49        "WHEN l.Action IN ('Push','Decrease','FbsNoWarehouse') THEN l.OneCQty " +
 50        "WHEN l.Action = 'BatchVariant' AND l.BatchMultiplier > 1 THEN FLOOR(l.OneCQty / l.BatchMultiplier) ELSE NULL EN
 51
 52    /// <summary>Акция — живой признак SaleFlag в snapshot 1С (как GetReportPromosAsync), а не кэш InPromo строки.</summ
 53    internal const string InPromoSql =
 54        "(CASE WHEN EXISTS (SELECT 1 FROM WarehouseStockSnapshot s WHERE s.Article = l.Article AND s.SaleFlag IS NOT NUL
 55
 56    internal const string ArchiveSql =
 57        "(CASE WHEN l.IsArchived = 1 AND l.IsAutoArchived = 1 THEN 'auto' WHEN l.IsArchived = 1 THEN 'manual' ELSE 'none
 58
 59    internal const string ManufacturerSql =
 60        "(SELECT s.Manufacturer FROM WarehouseStockSnapshot s WHERE s.Article = l.Article AND s.Manufacturer IS NOT NULL
 61
 62    private enum ColType { Text, Number, Sort }
 63
 8364    private sealed record Column(string Sql, ColType Type, bool NeedsPrice = false);
 65
 166    private static readonly Dictionary<string, Column> Columns = new(StringComparer.Ordinal)
 167    {
 168        ["article"] = new("l.Article", ColType.Text),
 169        ["offerId"] = new("l.OfferId", ColType.Text),
 170        ["oneCStore"] = new("l.OneCStore", ColType.Text),
 171        ["ozonWarehouseName"] = new("l.OzonWarehouseName", ColType.Text),
 172        ["ozonClientName"] = new("l.OzonClientName", ColType.Text),
 173        ["ozonClientId"] = new("CAST(COALESCE(l.OzonClientId, 0) AS CHAR)", ColType.Text),
 174        ["mappingDescription"] = new("l.MappingDescription", ColType.Text),
 175        ["note"] = new("l.Note", ColType.Text),
 176        ["action"] = new("l.Action", ColType.Text),
 177        ["ozonApplyStatus"] = new("COALESCE(l.OzonApplyStatus, '')", ColType.Text),
 178        ["ozonApplyError"] = new("l.OzonApplyError", ColType.Text),
 179        ["ozonCleanupStatus"] = new("COALESCE(l.OzonCleanupStatus, '')", ColType.Text),
 180        ["ozonCleanupError"] = new("l.OzonCleanupError", ColType.Text),
 181        ["archive"] = new(ArchiveSql, ColType.Text),
 182        ["inPromo"] = new(InPromoSql, ColType.Text),
 183        ["isOzonOrphan"] = new("(CASE WHEN l.IsOzonOrphan = 1 THEN 'yes' ELSE 'no' END)", ColType.Text),
 184        ["deltaC0"] = new(DeltaC0Sql, ColType.Text),
 185        ["colorIndex"] = new(ColorIndexSql, ColType.Text, NeedsPrice: true),
 186        ["pricePushStatus"] = new("COALESCE(p.PricePushStatus, '')", ColType.Text, NeedsPrice: true),
 187        ["oneCQty"] = new("l.OneCQty", ColType.Number),
 188        ["ozonQty"] = new("l.OzonQty", ColType.Number),
 189        ["delta"] = new("l.Delta", ColType.Number),
 190        ["reservedQty"] = new("l.ReservedQty", ColType.Number),
 191        ["targetQty"] = new(TargetQtySql, ColType.Number),
 192        ["ozonQtyAfter"] = new("l.OzonQtyAfter", ColType.Number),
 193        ["oneCPrice"] = new("l.OneCPrice", ColType.Number),
 194        ["prevOneCPrice"] = new("l.PrevOneCPrice", ColType.Number),
 195        ["batchMultiplier"] = new("l.BatchMultiplier", ColType.Number),
 196        ["ozonProductId"] = new("l.OzonProductId", ColType.Number),
 197        ["truePrice"] = new(TruePriceSql, ColType.Number, NeedsPrice: true),
 198        ["sellingPrice"] = new(SellingPriceSql, ColType.Number, NeedsPrice: true),
 199        ["pricePushedNew"] = new("p.PricePushedNew", ColType.Number, NeedsPrice: true),
 1100        ["ozonAppliedUtc"] = new("l.OzonAppliedUtc", ColType.Sort),
 1101        ["ozonCleanupAtUtc"] = new("l.OzonCleanupAtUtc", ColType.Sort)
 1102    };
 103
 104    /// <summary>Колонки, по которым сервер умеет сортировать.</summary>
 2105    public static bool IsSortable(string? colId) => colId != null && Columns.ContainsKey(colId);
 106
 107    /// <summary>Колонки, по которым сервер умеет фильтровать (и какого вида фильтр допустим).</summary>
 108    public static bool IsFilterable(string? colId, BitrixSyncGridConditionKind kind)
 109    {
 13110        if (colId == null || !Columns.TryGetValue(colId, out var col)) return false;
 13111        return kind switch
 13112        {
 7113            BitrixSyncGridConditionKind.Number => col.Type == ColType.Number,
 6114            _ => col.Type == ColType.Text
 13115        };
 116    }
 117
 118    /// <summary>GROUP BY-выражение колонки для счётчиков значений (только текстовые/справочные колонки).</summary>
 119    public static bool TryGetFacetExpression(string colId, out string sql, out bool needsPrice)
 120    {
 0121        if (Columns.TryGetValue(colId, out var col) && col.Type == ColType.Text)
 122        {
 0123            sql = col.Sql;
 0124            needsPrice = col.NeedsPrice;
 0125            return true;
 126        }
 127
 0128        sql = string.Empty;
 0129        needsPrice = false;
 0130        return false;
 131    }
 132
 133    public sealed class Built
 134    {
 55135        public string Where { get; init; } = string.Empty;
 42136        public string OrderBy { get; init; } = string.Empty;
 23137        public bool NeedsPriceJoin { get; init; }
 43138        public List<KeyValuePair<string, object?>> Parameters { get; } = new();
 139    }
 140
 141    /// <summary>Джойн последнего снимка цены Ozon по (артикул, клиент) — в таблице цен нет уникального ключа.</summary>
 142    public const string PriceJoinSql = @"
 143LEFT JOIN (SELECT MAX(Id) AS Id, Article, OzonClientId FROM BitrixStockSyncReportOzonPrices
 144           WHERE ReportId = @reportId GROUP BY Article, OzonClientId) pm
 145       ON pm.Article = l.Article AND pm.OzonClientId = l.OzonClientId
 146LEFT JOIN BitrixStockSyncReportOzonPrices p ON p.Id = pm.Id";
 147
 148    /// <summary>
 149    /// WHERE (с учётом отчёта, поиска, флагов, условий и исключённых связей) и ORDER BY для блока.
 150    /// <paramref name="excludeColId"/> — колонка, условие которой не применяется (фасеты считаются без неё).
 151    /// </summary>
 152    public static Built Build(string reportId, BitrixSyncGridLinesQuery query, string? excludeColId = null)
 153    {
 19154        var parameters = new List<KeyValuePair<string, object?>>();
 19155        var where = new StringBuilder(" WHERE l.ReportId = @reportId");
 19156        parameters.Add(new("@reportId", reportId));
 19157        var needsPrice = false;
 158
 19159        var search = Clip(query.Search);
 19160        if (!string.IsNullOrEmpty(search))
 161        {
 2162            where.Append(" AND (l.Article LIKE @q OR l.OfferId LIKE @q OR l.OneCStore LIKE @q OR l.OzonWarehouseName LIK
 2163            parameters.Add(new("@q", "%" + EscapeLike(search) + "%"));
 164        }
 165
 166        // Строки 0/0 — шум; при поиске конкретного артикула показываем всё найденное.
 19167        if (!query.ShowZero && string.IsNullOrEmpty(search))
 168        {
 16169            where.Append(" AND (l.Action = 'CleanupExtra' OR NOT (l.OneCQty = 0 AND l.OzonQty = 0))");
 170        }
 171
 19172        if (query.OnlyPriceChanged)
 173        {
 1174            needsPrice = true;
 1175            where.Append(" AND (p.PricePushedOld IS NOT NULL OR p.PricePushStatus IS NOT NULL)");
 176        }
 177
 19178        var excluded = (query.ExcludedMappings ?? new List<string>())
 1179            .Where(m => !string.IsNullOrWhiteSpace(m))
 19180            .Distinct(StringComparer.Ordinal)
 19181            .Take(MaxExcludedMappings)
 19182            .ToList();
 19183        if (excluded.Count > 0)
 184        {
 1185            var names = new List<string>(excluded.Count);
 4186            for (var i = 0; i < excluded.Count; i++)
 187            {
 1188                var name = "@exm" + i.ToString(CultureInfo.InvariantCulture);
 1189                names.Add(name);
 1190                parameters.Add(new(name, excluded[i]));
 191            }
 192
 1193            where.Append(" AND CONCAT(l.OzonClientId, '|', l.OneCStore, '|', l.OzonWarehouseName) NOT IN (")
 1194                .Append(string.Join(", ", names)).Append(')');
 195        }
 196
 19197        var index = 0;
 62198        foreach (var condition in (query.Conditions ?? new List<BitrixSyncGridCondition>()).Take(MaxConditions))
 199        {
 12200            if (condition.ColId == excludeColId) continue;
 11201            if (!Columns.TryGetValue(condition.ColId, out var col)) continue;
 10202            if (!IsFilterable(condition.ColId, condition.Kind)) continue;
 9203            var clause = BuildCondition(col.Sql, condition, "@c" + index.ToString(CultureInfo.InvariantCulture), paramet
 9204            index++;
 9205            if (clause == null) continue;
 9206            needsPrice |= col.NeedsPrice;
 9207            where.Append(" AND ").Append(clause);
 208        }
 209
 19210        var order = new StringBuilder();
 19211        var sortCount = 0;
 53212        foreach (var sort in query.Sorts ?? new List<BitrixSyncGridSort>())
 213        {
 9214            if (sortCount >= MaxSorts) break;
 7215            if (!Columns.TryGetValue(sort.ColId, out var col)) continue;
 6216            needsPrice |= col.NeedsPrice;
 6217            order.Append(order.Length == 0 ? " ORDER BY " : ", ");
 218            // NULL в конец независимо от направления — как в гриде заказов.
 6219            order.Append('(').Append(col.Sql).Append(") IS NULL, ").Append(col.Sql).Append(sort.Descending ? " DESC" : "
 6220            sortCount++;
 221        }
 222
 19223        order.Append(order.Length == 0 ? " ORDER BY " : ", ");
 19224        order.Append("l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonClientId, l.Id");
 225
 19226        var built = new Built { Where = where.ToString(), OrderBy = order.ToString(), NeedsPriceJoin = needsPrice };
 19227        built.Parameters.AddRange(parameters);
 19228        return built;
 229    }
 230
 231    private static string? BuildCondition(string sql, BitrixSyncGridCondition c, string p, List<KeyValuePair<string, obj
 232    {
 9233        switch (c.Kind)
 234        {
 235            case BitrixSyncGridConditionKind.Set:
 236            {
 8237                var values = (c.Values ?? new List<string>()).Where(v => v != null).Distinct(StringComparer.Ordinal).Tak
 5238                if (values.Count == 0) return "1 = 0";
 3239                var names = new List<string>(values.Count);
 14240                for (var i = 0; i < values.Count; i++)
 241                {
 4242                    var name = p + "_" + i.ToString(CultureInfo.InvariantCulture);
 4243                    names.Add(name);
 4244                    parameters.Add(new(name, values[i]));
 245                }
 246
 247                // Пустое значение в наборе — «(пусто)»: COALESCE сводит NULL к ''.
 3248                return "COALESCE(" + sql + ", '') IN (" + string.Join(", ", names) + ")";
 249            }
 250            case BitrixSyncGridConditionKind.Number:
 5251                return BuildNumber(sql, c, p, parameters);
 252            default:
 0253                return BuildText(sql, c, p, parameters);
 254        }
 255    }
 256
 257    private static string? BuildText(string sql, BitrixSyncGridCondition c, string p, List<KeyValuePair<string, object?>
 258    {
 0259        switch (c.Operator)
 260        {
 0261            case "blank": return "(" + sql + " IS NULL OR " + sql + " = '')";
 0262            case "notBlank": return "(" + sql + " IS NOT NULL AND " + sql + " <> '')";
 263        }
 264
 0265        var text = Clip(c.Text);
 0266        if (string.IsNullOrEmpty(text)) return null;
 0267        var escaped = EscapeLike(text);
 0268        switch (c.Operator)
 269        {
 270            case "equals":
 0271                parameters.Add(new(p, text));
 0272                return sql + " = " + p;
 273            case "notEqual":
 0274                parameters.Add(new(p, text));
 0275                return "(" + sql + " IS NULL OR " + sql + " <> " + p + ")";
 276            case "startsWith":
 0277                parameters.Add(new(p, escaped + "%"));
 0278                return sql + " LIKE " + p;
 279            case "endsWith":
 0280                parameters.Add(new(p, "%" + escaped));
 0281                return sql + " LIKE " + p;
 282            case "notContains":
 0283                parameters.Add(new(p, "%" + escaped + "%"));
 0284                return "(" + sql + " IS NULL OR " + sql + " NOT LIKE " + p + ")";
 285            default:
 0286                parameters.Add(new(p, "%" + escaped + "%"));
 0287                return sql + " LIKE " + p;
 288        }
 289    }
 290
 291    private static string? BuildNumber(string sql, BitrixSyncGridCondition c, string p, List<KeyValuePair<string, object
 292    {
 5293        switch (c.Operator)
 294        {
 0295            case "blank": return sql + " IS NULL";
 0296            case "notBlank": return sql + " IS NOT NULL";
 297        }
 298
 5299        if (c.Number == null) return null;
 5300        parameters.Add(new(p, c.Number.Value));
 5301        switch (c.Operator)
 302        {
 1303            case "notEqual": return "(" + sql + " IS NULL OR " + sql + " <> " + p + ")";
 1304            case "greaterThan": return sql + " > " + p;
 0305            case "greaterThanOrEqual": return sql + " >= " + p;
 0306            case "lessThan": return sql + " < " + p;
 1307            case "lessThanOrEqual": return sql + " <= " + p;
 308            case "inRange":
 1309                if (c.NumberTo == null) return sql + " >= " + p;
 1310                parameters.Add(new(p + "_to", c.NumberTo.Value));
 1311                return sql + " BETWEEN " + p + " AND " + p + "_to";
 1312            default: return sql + " = " + p;
 313        }
 314    }
 315
 316    /// <summary>SELECT-список строки грида (порядок колонок читает сервис).</summary>
 317    public const string SelectColumns = @"l.Id, l.ProductId, l.Article, l.OfferId, l.OneCStore, l.OzonWarehouseName, l.O
 318l.OneCQty, l.OzonQty, l.Delta, COALESCE(l.ReservedQty, 0), l.OzonQtyAfter, l.Action, l.ActionLabel, l.ActionIcon, l.RowC
 319l.OzonApplyStatus, l.OzonAppliedUtc, l.OzonApplyError, l.OzonCleanupStatus, l.OzonCleanupAtUtc, l.OzonCleanupError,
 320COALESCE(l.OzonClientId, 0), COALESCE(l.OzonClientName, ''), l.OneCPrice, l.PrevOneCPrice,
 321l.IsArchived, l.IsAutoArchived, l.PromoName, l.IsOzonOrphan, l.OzonOrphanStatus,
 322COALESCE(l.BatchMultiplier, 1), l.BatchBaseArticle, l.BatchPushedPrice, "
 323        + ManufacturerSql + ", " + DeltaC0Sql + ", " + TargetQtySql + ", " + InPromoSql + ", "
 324        + TruePriceSql + ", " + SellingPriceSql + ", " + ColorIndexSql + ", p.PricePushedOld, p.PricePushedNew, p.PriceP
 325
 326    private static string? Clip(string? value)
 327    {
 36328        if (string.IsNullOrWhiteSpace(value)) return null;
 2329        var trimmed = value.Trim();
 2330        return trimmed.Length > MaxTextLength ? trimmed[..MaxTextLength] : trimmed;
 331    }
 332
 333    /// <summary>Экранирование спецсимволов LIKE (экранирующий символ MariaDB по умолчанию — обратный слэш).</summary>
 334    internal static string EscapeLike(string value)
 2335        => value.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
 336}