| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text; |
| | | 3 | | |
| | | 4 | | namespace Services.BitrixStockServices; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Построение SQL для грида строк отчёта синхронизации: whitelist колонок (colId → SQL-выражение), операторы |
| | | 8 | | /// фильтров AG Grid, сортировки и быстрый поиск. Значения пользователя всегда уходят параметрами, в текст SQL |
| | | 9 | | /// попадают только выражения из каталога. Класс чистый (без БД) — покрыт unit-тестами. |
| | | 10 | | /// </summary> |
| | | 11 | | public 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 | | |
| | 83 | 64 | | private sealed record Column(string Sql, ColType Type, bool NeedsPrice = false); |
| | | 65 | | |
| | 1 | 66 | | private static readonly Dictionary<string, Column> Columns = new(StringComparer.Ordinal) |
| | 1 | 67 | | { |
| | 1 | 68 | | ["article"] = new("l.Article", ColType.Text), |
| | 1 | 69 | | ["offerId"] = new("l.OfferId", ColType.Text), |
| | 1 | 70 | | ["oneCStore"] = new("l.OneCStore", ColType.Text), |
| | 1 | 71 | | ["ozonWarehouseName"] = new("l.OzonWarehouseName", ColType.Text), |
| | 1 | 72 | | ["ozonClientName"] = new("l.OzonClientName", ColType.Text), |
| | 1 | 73 | | ["ozonClientId"] = new("CAST(COALESCE(l.OzonClientId, 0) AS CHAR)", ColType.Text), |
| | 1 | 74 | | ["mappingDescription"] = new("l.MappingDescription", ColType.Text), |
| | 1 | 75 | | ["note"] = new("l.Note", ColType.Text), |
| | 1 | 76 | | ["action"] = new("l.Action", ColType.Text), |
| | 1 | 77 | | ["ozonApplyStatus"] = new("COALESCE(l.OzonApplyStatus, '')", ColType.Text), |
| | 1 | 78 | | ["ozonApplyError"] = new("l.OzonApplyError", ColType.Text), |
| | 1 | 79 | | ["ozonCleanupStatus"] = new("COALESCE(l.OzonCleanupStatus, '')", ColType.Text), |
| | 1 | 80 | | ["ozonCleanupError"] = new("l.OzonCleanupError", ColType.Text), |
| | 1 | 81 | | ["archive"] = new(ArchiveSql, ColType.Text), |
| | 1 | 82 | | ["inPromo"] = new(InPromoSql, ColType.Text), |
| | 1 | 83 | | ["isOzonOrphan"] = new("(CASE WHEN l.IsOzonOrphan = 1 THEN 'yes' ELSE 'no' END)", ColType.Text), |
| | 1 | 84 | | ["deltaC0"] = new(DeltaC0Sql, ColType.Text), |
| | 1 | 85 | | ["colorIndex"] = new(ColorIndexSql, ColType.Text, NeedsPrice: true), |
| | 1 | 86 | | ["pricePushStatus"] = new("COALESCE(p.PricePushStatus, '')", ColType.Text, NeedsPrice: true), |
| | 1 | 87 | | ["oneCQty"] = new("l.OneCQty", ColType.Number), |
| | 1 | 88 | | ["ozonQty"] = new("l.OzonQty", ColType.Number), |
| | 1 | 89 | | ["delta"] = new("l.Delta", ColType.Number), |
| | 1 | 90 | | ["reservedQty"] = new("l.ReservedQty", ColType.Number), |
| | 1 | 91 | | ["targetQty"] = new(TargetQtySql, ColType.Number), |
| | 1 | 92 | | ["ozonQtyAfter"] = new("l.OzonQtyAfter", ColType.Number), |
| | 1 | 93 | | ["oneCPrice"] = new("l.OneCPrice", ColType.Number), |
| | 1 | 94 | | ["prevOneCPrice"] = new("l.PrevOneCPrice", ColType.Number), |
| | 1 | 95 | | ["batchMultiplier"] = new("l.BatchMultiplier", ColType.Number), |
| | 1 | 96 | | ["ozonProductId"] = new("l.OzonProductId", ColType.Number), |
| | 1 | 97 | | ["truePrice"] = new(TruePriceSql, ColType.Number, NeedsPrice: true), |
| | 1 | 98 | | ["sellingPrice"] = new(SellingPriceSql, ColType.Number, NeedsPrice: true), |
| | 1 | 99 | | ["pricePushedNew"] = new("p.PricePushedNew", ColType.Number, NeedsPrice: true), |
| | 1 | 100 | | ["ozonAppliedUtc"] = new("l.OzonAppliedUtc", ColType.Sort), |
| | 1 | 101 | | ["ozonCleanupAtUtc"] = new("l.OzonCleanupAtUtc", ColType.Sort) |
| | 1 | 102 | | }; |
| | | 103 | | |
| | | 104 | | /// <summary>Колонки, по которым сервер умеет сортировать.</summary> |
| | 2 | 105 | | 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 | | { |
| | 13 | 110 | | if (colId == null || !Columns.TryGetValue(colId, out var col)) return false; |
| | 13 | 111 | | return kind switch |
| | 13 | 112 | | { |
| | 7 | 113 | | BitrixSyncGridConditionKind.Number => col.Type == ColType.Number, |
| | 6 | 114 | | _ => col.Type == ColType.Text |
| | 13 | 115 | | }; |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | /// <summary>GROUP BY-выражение колонки для счётчиков значений (только текстовые/справочные колонки).</summary> |
| | | 119 | | public static bool TryGetFacetExpression(string colId, out string sql, out bool needsPrice) |
| | | 120 | | { |
| | 0 | 121 | | if (Columns.TryGetValue(colId, out var col) && col.Type == ColType.Text) |
| | | 122 | | { |
| | 0 | 123 | | sql = col.Sql; |
| | 0 | 124 | | needsPrice = col.NeedsPrice; |
| | 0 | 125 | | return true; |
| | | 126 | | } |
| | | 127 | | |
| | 0 | 128 | | sql = string.Empty; |
| | 0 | 129 | | needsPrice = false; |
| | 0 | 130 | | return false; |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | public sealed class Built |
| | | 134 | | { |
| | 55 | 135 | | public string Where { get; init; } = string.Empty; |
| | 42 | 136 | | public string OrderBy { get; init; } = string.Empty; |
| | 23 | 137 | | public bool NeedsPriceJoin { get; init; } |
| | 43 | 138 | | public List<KeyValuePair<string, object?>> Parameters { get; } = new(); |
| | | 139 | | } |
| | | 140 | | |
| | | 141 | | /// <summary>Джойн последнего снимка цены Ozon по (артикул, клиент) — в таблице цен нет уникального ключа.</summary> |
| | | 142 | | public const string PriceJoinSql = @" |
| | | 143 | | LEFT 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 |
| | | 146 | | LEFT 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 | | { |
| | 19 | 154 | | var parameters = new List<KeyValuePair<string, object?>>(); |
| | 19 | 155 | | var where = new StringBuilder(" WHERE l.ReportId = @reportId"); |
| | 19 | 156 | | parameters.Add(new("@reportId", reportId)); |
| | 19 | 157 | | var needsPrice = false; |
| | | 158 | | |
| | 19 | 159 | | var search = Clip(query.Search); |
| | 19 | 160 | | if (!string.IsNullOrEmpty(search)) |
| | | 161 | | { |
| | 2 | 162 | | where.Append(" AND (l.Article LIKE @q OR l.OfferId LIKE @q OR l.OneCStore LIKE @q OR l.OzonWarehouseName LIK |
| | 2 | 163 | | parameters.Add(new("@q", "%" + EscapeLike(search) + "%")); |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | // Строки 0/0 — шум; при поиске конкретного артикула показываем всё найденное. |
| | 19 | 167 | | if (!query.ShowZero && string.IsNullOrEmpty(search)) |
| | | 168 | | { |
| | 16 | 169 | | where.Append(" AND (l.Action = 'CleanupExtra' OR NOT (l.OneCQty = 0 AND l.OzonQty = 0))"); |
| | | 170 | | } |
| | | 171 | | |
| | 19 | 172 | | if (query.OnlyPriceChanged) |
| | | 173 | | { |
| | 1 | 174 | | needsPrice = true; |
| | 1 | 175 | | where.Append(" AND (p.PricePushedOld IS NOT NULL OR p.PricePushStatus IS NOT NULL)"); |
| | | 176 | | } |
| | | 177 | | |
| | 19 | 178 | | var excluded = (query.ExcludedMappings ?? new List<string>()) |
| | 1 | 179 | | .Where(m => !string.IsNullOrWhiteSpace(m)) |
| | 19 | 180 | | .Distinct(StringComparer.Ordinal) |
| | 19 | 181 | | .Take(MaxExcludedMappings) |
| | 19 | 182 | | .ToList(); |
| | 19 | 183 | | if (excluded.Count > 0) |
| | | 184 | | { |
| | 1 | 185 | | var names = new List<string>(excluded.Count); |
| | 4 | 186 | | for (var i = 0; i < excluded.Count; i++) |
| | | 187 | | { |
| | 1 | 188 | | var name = "@exm" + i.ToString(CultureInfo.InvariantCulture); |
| | 1 | 189 | | names.Add(name); |
| | 1 | 190 | | parameters.Add(new(name, excluded[i])); |
| | | 191 | | } |
| | | 192 | | |
| | 1 | 193 | | where.Append(" AND CONCAT(l.OzonClientId, '|', l.OneCStore, '|', l.OzonWarehouseName) NOT IN (") |
| | 1 | 194 | | .Append(string.Join(", ", names)).Append(')'); |
| | | 195 | | } |
| | | 196 | | |
| | 19 | 197 | | var index = 0; |
| | 62 | 198 | | foreach (var condition in (query.Conditions ?? new List<BitrixSyncGridCondition>()).Take(MaxConditions)) |
| | | 199 | | { |
| | 12 | 200 | | if (condition.ColId == excludeColId) continue; |
| | 11 | 201 | | if (!Columns.TryGetValue(condition.ColId, out var col)) continue; |
| | 10 | 202 | | if (!IsFilterable(condition.ColId, condition.Kind)) continue; |
| | 9 | 203 | | var clause = BuildCondition(col.Sql, condition, "@c" + index.ToString(CultureInfo.InvariantCulture), paramet |
| | 9 | 204 | | index++; |
| | 9 | 205 | | if (clause == null) continue; |
| | 9 | 206 | | needsPrice |= col.NeedsPrice; |
| | 9 | 207 | | where.Append(" AND ").Append(clause); |
| | | 208 | | } |
| | | 209 | | |
| | 19 | 210 | | var order = new StringBuilder(); |
| | 19 | 211 | | var sortCount = 0; |
| | 53 | 212 | | foreach (var sort in query.Sorts ?? new List<BitrixSyncGridSort>()) |
| | | 213 | | { |
| | 9 | 214 | | if (sortCount >= MaxSorts) break; |
| | 7 | 215 | | if (!Columns.TryGetValue(sort.ColId, out var col)) continue; |
| | 6 | 216 | | needsPrice |= col.NeedsPrice; |
| | 6 | 217 | | order.Append(order.Length == 0 ? " ORDER BY " : ", "); |
| | | 218 | | // NULL в конец независимо от направления — как в гриде заказов. |
| | 6 | 219 | | order.Append('(').Append(col.Sql).Append(") IS NULL, ").Append(col.Sql).Append(sort.Descending ? " DESC" : " |
| | 6 | 220 | | sortCount++; |
| | | 221 | | } |
| | | 222 | | |
| | 19 | 223 | | order.Append(order.Length == 0 ? " ORDER BY " : ", "); |
| | 19 | 224 | | order.Append("l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonClientId, l.Id"); |
| | | 225 | | |
| | 19 | 226 | | var built = new Built { Where = where.ToString(), OrderBy = order.ToString(), NeedsPriceJoin = needsPrice }; |
| | 19 | 227 | | built.Parameters.AddRange(parameters); |
| | 19 | 228 | | return built; |
| | | 229 | | } |
| | | 230 | | |
| | | 231 | | private static string? BuildCondition(string sql, BitrixSyncGridCondition c, string p, List<KeyValuePair<string, obj |
| | | 232 | | { |
| | 9 | 233 | | switch (c.Kind) |
| | | 234 | | { |
| | | 235 | | case BitrixSyncGridConditionKind.Set: |
| | | 236 | | { |
| | 8 | 237 | | var values = (c.Values ?? new List<string>()).Where(v => v != null).Distinct(StringComparer.Ordinal).Tak |
| | 5 | 238 | | if (values.Count == 0) return "1 = 0"; |
| | 3 | 239 | | var names = new List<string>(values.Count); |
| | 14 | 240 | | for (var i = 0; i < values.Count; i++) |
| | | 241 | | { |
| | 4 | 242 | | var name = p + "_" + i.ToString(CultureInfo.InvariantCulture); |
| | 4 | 243 | | names.Add(name); |
| | 4 | 244 | | parameters.Add(new(name, values[i])); |
| | | 245 | | } |
| | | 246 | | |
| | | 247 | | // Пустое значение в наборе — «(пусто)»: COALESCE сводит NULL к ''. |
| | 3 | 248 | | return "COALESCE(" + sql + ", '') IN (" + string.Join(", ", names) + ")"; |
| | | 249 | | } |
| | | 250 | | case BitrixSyncGridConditionKind.Number: |
| | 5 | 251 | | return BuildNumber(sql, c, p, parameters); |
| | | 252 | | default: |
| | 0 | 253 | | 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 | | { |
| | 0 | 259 | | switch (c.Operator) |
| | | 260 | | { |
| | 0 | 261 | | case "blank": return "(" + sql + " IS NULL OR " + sql + " = '')"; |
| | 0 | 262 | | case "notBlank": return "(" + sql + " IS NOT NULL AND " + sql + " <> '')"; |
| | | 263 | | } |
| | | 264 | | |
| | 0 | 265 | | var text = Clip(c.Text); |
| | 0 | 266 | | if (string.IsNullOrEmpty(text)) return null; |
| | 0 | 267 | | var escaped = EscapeLike(text); |
| | 0 | 268 | | switch (c.Operator) |
| | | 269 | | { |
| | | 270 | | case "equals": |
| | 0 | 271 | | parameters.Add(new(p, text)); |
| | 0 | 272 | | return sql + " = " + p; |
| | | 273 | | case "notEqual": |
| | 0 | 274 | | parameters.Add(new(p, text)); |
| | 0 | 275 | | return "(" + sql + " IS NULL OR " + sql + " <> " + p + ")"; |
| | | 276 | | case "startsWith": |
| | 0 | 277 | | parameters.Add(new(p, escaped + "%")); |
| | 0 | 278 | | return sql + " LIKE " + p; |
| | | 279 | | case "endsWith": |
| | 0 | 280 | | parameters.Add(new(p, "%" + escaped)); |
| | 0 | 281 | | return sql + " LIKE " + p; |
| | | 282 | | case "notContains": |
| | 0 | 283 | | parameters.Add(new(p, "%" + escaped + "%")); |
| | 0 | 284 | | return "(" + sql + " IS NULL OR " + sql + " NOT LIKE " + p + ")"; |
| | | 285 | | default: |
| | 0 | 286 | | parameters.Add(new(p, "%" + escaped + "%")); |
| | 0 | 287 | | return sql + " LIKE " + p; |
| | | 288 | | } |
| | | 289 | | } |
| | | 290 | | |
| | | 291 | | private static string? BuildNumber(string sql, BitrixSyncGridCondition c, string p, List<KeyValuePair<string, object |
| | | 292 | | { |
| | 5 | 293 | | switch (c.Operator) |
| | | 294 | | { |
| | 0 | 295 | | case "blank": return sql + " IS NULL"; |
| | 0 | 296 | | case "notBlank": return sql + " IS NOT NULL"; |
| | | 297 | | } |
| | | 298 | | |
| | 5 | 299 | | if (c.Number == null) return null; |
| | 5 | 300 | | parameters.Add(new(p, c.Number.Value)); |
| | 5 | 301 | | switch (c.Operator) |
| | | 302 | | { |
| | 1 | 303 | | case "notEqual": return "(" + sql + " IS NULL OR " + sql + " <> " + p + ")"; |
| | 1 | 304 | | case "greaterThan": return sql + " > " + p; |
| | 0 | 305 | | case "greaterThanOrEqual": return sql + " >= " + p; |
| | 0 | 306 | | case "lessThan": return sql + " < " + p; |
| | 1 | 307 | | case "lessThanOrEqual": return sql + " <= " + p; |
| | | 308 | | case "inRange": |
| | 1 | 309 | | if (c.NumberTo == null) return sql + " >= " + p; |
| | 1 | 310 | | parameters.Add(new(p + "_to", c.NumberTo.Value)); |
| | 1 | 311 | | return sql + " BETWEEN " + p + " AND " + p + "_to"; |
| | 1 | 312 | | 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 |
| | | 318 | | l.OneCQty, l.OzonQty, l.Delta, COALESCE(l.ReservedQty, 0), l.OzonQtyAfter, l.Action, l.ActionLabel, l.ActionIcon, l.RowC |
| | | 319 | | l.OzonApplyStatus, l.OzonAppliedUtc, l.OzonApplyError, l.OzonCleanupStatus, l.OzonCleanupAtUtc, l.OzonCleanupError, |
| | | 320 | | COALESCE(l.OzonClientId, 0), COALESCE(l.OzonClientName, ''), l.OneCPrice, l.PrevOneCPrice, |
| | | 321 | | l.IsArchived, l.IsAutoArchived, l.PromoName, l.IsOzonOrphan, l.OzonOrphanStatus, |
| | | 322 | | COALESCE(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 | | { |
| | 36 | 328 | | if (string.IsNullOrWhiteSpace(value)) return null; |
| | 2 | 329 | | var trimmed = value.Trim(); |
| | 2 | 330 | | return trimmed.Length > MaxTextLength ? trimmed[..MaxTextLength] : trimmed; |
| | | 331 | | } |
| | | 332 | | |
| | | 333 | | /// <summary>Экранирование спецсимволов LIKE (экранирующий символ MariaDB по умолчанию — обратный слэш).</summary> |
| | | 334 | | internal static string EscapeLike(string value) |
| | 2 | 335 | | => value.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); |
| | | 336 | | } |