| | | 1 | | using System.Text.Json; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using Microsoft.Extensions.Caching.Memory; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using Newtonsoft.Json.Linq; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | |
| | | 8 | | namespace Services.BitrixStockServices; |
| | | 9 | | |
| | 7 | 10 | | public class BitrixStockSyncArticleHistoryService( |
| | 7 | 11 | | IDbContextFactory<ApplicationDbContext> dbFactory, |
| | 7 | 12 | | IMemoryCache cache, |
| | 7 | 13 | | ILogger<BitrixStockSyncArticleHistoryService> logger) : IBitrixStockSyncArticleHistoryService |
| | | 14 | | { |
| | 0 | 15 | | private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); |
| | | 16 | | private const string CompletedStatus = "Completed"; |
| | | 17 | | |
| | | 18 | | public async Task<BitrixStockArticleHistoryPageDto> GetPageAsync( |
| | | 19 | | string article, |
| | | 20 | | int page = 1, |
| | | 21 | | int pageSize = 20, |
| | | 22 | | CancellationToken cancellationToken = default) |
| | | 23 | | { |
| | 4 | 24 | | article = article.Trim(); |
| | 4 | 25 | | if (string.IsNullOrEmpty(article)) |
| | 2 | 26 | | return new BitrixStockArticleHistoryPageDto { Article = article }; |
| | | 27 | | |
| | 2 | 28 | | page = Math.Max(1, page); |
| | 2 | 29 | | pageSize = Math.Clamp(pageSize, 1, 50); |
| | | 30 | | // Ключ по нормализованному артикулу: «8934833060A0» и «89348-33060-A0» дают один результат. |
| | 2 | 31 | | var cacheKey = $"bitrix-art-hist:{BitrixStockArticleVariants.Normalize(article)}:{page}:{pageSize}"; |
| | 2 | 32 | | if (cache.TryGetValue<BitrixStockArticleHistoryPageDto>(cacheKey, out var cached) && cached != null) |
| | 2 | 33 | | return cached; |
| | | 34 | | |
| | 0 | 35 | | await using var db = await dbFactory.CreateDbContextAsync(cancellationToken); |
| | 0 | 36 | | var conn = db.Database.GetDbConnection(); |
| | 0 | 37 | | if (conn.State != System.Data.ConnectionState.Open) |
| | 0 | 38 | | await conn.OpenAsync(cancellationToken); |
| | | 39 | | |
| | 0 | 40 | | var articles = await ResolveArticleVariantsAsync(conn, article, cancellationToken); |
| | 0 | 41 | | var totalCount = await CountReportsAsync(conn, articles, cancellationToken); |
| | 0 | 42 | | var globalFlags = await LoadGlobalColumnFlagsAsync(conn, articles, cancellationToken); |
| | 0 | 43 | | var result = new BitrixStockArticleHistoryPageDto |
| | 0 | 44 | | { |
| | 0 | 45 | | Article = article, |
| | 0 | 46 | | Page = page, |
| | 0 | 47 | | PageSize = pageSize, |
| | 0 | 48 | | TotalReportCount = totalCount, |
| | 0 | 49 | | HasPrices = globalFlags.HasPrices, |
| | 0 | 50 | | HasPushes = globalFlags.HasPushes, |
| | 0 | 51 | | IsMultiClient = globalFlags.IsMultiClient |
| | 0 | 52 | | }; |
| | | 53 | | |
| | 0 | 54 | | if (totalCount == 0) |
| | | 55 | | { |
| | 0 | 56 | | cache.Set(cacheKey, result, CacheTtl); |
| | 0 | 57 | | return result; |
| | | 58 | | } |
| | | 59 | | |
| | 0 | 60 | | var reportMetas = await LoadReportMetasAsync(conn, articles, page, pageSize, cancellationToken); |
| | 0 | 61 | | if (reportMetas.Count == 0) |
| | | 62 | | { |
| | 0 | 63 | | cache.Set(cacheKey, result, CacheTtl); |
| | 0 | 64 | | return result; |
| | | 65 | | } |
| | | 66 | | |
| | 0 | 67 | | var reportIds = reportMetas.Select(r => r.ReportId).ToList(); |
| | 0 | 68 | | var linesByReport = await LoadLinesAsync(conn, articles, reportIds, cancellationToken); |
| | 0 | 69 | | var pricesByReport = await LoadPricesAsync(conn, articles, reportIds, cancellationToken); |
| | | 70 | | |
| | 0 | 71 | | foreach (var meta in reportMetas) |
| | | 72 | | { |
| | 0 | 73 | | var block = new BitrixStockArticleHistoryReportBlockDto |
| | 0 | 74 | | { |
| | 0 | 75 | | ReportId = meta.ReportId, |
| | 0 | 76 | | GeneratedAtUtc = meta.GeneratedAtUtc, |
| | 0 | 77 | | OzonClientName = meta.OzonClientName, |
| | 0 | 78 | | MultiClientIds = meta.MultiClientIds.Count > 0 ? meta.MultiClientIds : new List<int> { meta.OzonClientId |
| | 0 | 79 | | }; |
| | | 80 | | |
| | 0 | 81 | | if (block.IsMultiClient) |
| | | 82 | | { |
| | 0 | 83 | | foreach (var row in linesByReport.GetValueOrDefault(meta.ReportId) ?? new List<BitrixStockSyncReportWare |
| | | 84 | | { |
| | 0 | 85 | | if (row.OzonClientId > 0 && !string.IsNullOrEmpty(row.OzonClientName)) |
| | 0 | 86 | | block.MultiClientNames[row.OzonClientId] = row.OzonClientName; |
| | | 87 | | } |
| | | 88 | | } |
| | | 89 | | |
| | 0 | 90 | | var priceMap = pricesByReport.GetValueOrDefault(meta.ReportId) |
| | 0 | 91 | | ?? new Dictionary<int, OzonPriceInfoDto>(); |
| | 0 | 92 | | block.HasPrices = priceMap.Count > 0; |
| | 0 | 93 | | block.HasPushes = priceMap.Values.Any(p => |
| | 0 | 94 | | p.PricePushedNew.HasValue || !string.IsNullOrEmpty(p.PricePushStatus)); |
| | | 95 | | |
| | 0 | 96 | | foreach (var line in linesByReport.GetValueOrDefault(meta.ReportId) ?? new List<BitrixStockSyncReportWarehou |
| | | 97 | | { |
| | 0 | 98 | | priceMap.TryGetValue(line.OzonClientId, out var price); |
| | 0 | 99 | | block.Rows.Add(new BitrixStockArticleHistoryRowDto { Line = line, Price = price }); |
| | | 100 | | } |
| | | 101 | | |
| | 0 | 102 | | result.Reports.Add(block); |
| | | 103 | | } |
| | | 104 | | |
| | 0 | 105 | | cache.Set(cacheKey, result, CacheTtl); |
| | 0 | 106 | | return result; |
| | 4 | 107 | | } |
| | | 108 | | |
| | | 109 | | public async Task<BitrixStockArticleHistoryPageDto> GetAllAsync( |
| | | 110 | | string article, |
| | | 111 | | int? maxReports = null, |
| | | 112 | | CancellationToken cancellationToken = default) |
| | | 113 | | { |
| | 3 | 114 | | article = article.Trim(); |
| | 3 | 115 | | if (string.IsNullOrEmpty(article)) |
| | 2 | 116 | | return new BitrixStockArticleHistoryPageDto { Article = article }; |
| | | 117 | | |
| | 1 | 118 | | var limit = maxReports is > 0 ? maxReports.Value : int.MaxValue; |
| | 1 | 119 | | var normalized = BitrixStockArticleVariants.Normalize(article); |
| | 1 | 120 | | var cacheKey = limit == int.MaxValue |
| | 1 | 121 | | ? $"bitrix-art-hist-all:{normalized}" |
| | 1 | 122 | | : $"bitrix-art-hist-all:{normalized}:{limit}"; |
| | 1 | 123 | | if (cache.TryGetValue<BitrixStockArticleHistoryPageDto>(cacheKey, out var cached) && cached != null) |
| | 1 | 124 | | return cached; |
| | | 125 | | |
| | | 126 | | const int pageSize = 50; |
| | 0 | 127 | | var first = await GetPageAsync(article, 1, pageSize, cancellationToken); |
| | 0 | 128 | | if (first.TotalReportCount == 0) |
| | | 129 | | { |
| | 0 | 130 | | cache.Set(cacheKey, first, CacheTtl); |
| | 0 | 131 | | return first; |
| | | 132 | | } |
| | | 133 | | |
| | 0 | 134 | | var merged = new BitrixStockArticleHistoryPageDto |
| | 0 | 135 | | { |
| | 0 | 136 | | Article = first.Article, |
| | 0 | 137 | | Page = 1, |
| | 0 | 138 | | PageSize = first.TotalReportCount, |
| | 0 | 139 | | TotalReportCount = first.TotalReportCount, |
| | 0 | 140 | | HasPrices = first.HasPrices, |
| | 0 | 141 | | HasPushes = first.HasPushes, |
| | 0 | 142 | | IsMultiClient = first.IsMultiClient, |
| | 0 | 143 | | ExportReportLimit = limit == int.MaxValue ? null : limit, |
| | 0 | 144 | | Reports = new List<BitrixStockArticleHistoryReportBlockDto>(first.Reports) |
| | 0 | 145 | | }; |
| | | 146 | | |
| | 0 | 147 | | BitrixStockArticleHistoryPaging.ApplyReportLimit(merged, limit); |
| | | 148 | | |
| | 0 | 149 | | foreach (var page in BitrixStockArticleHistoryPaging.EnumeratePagesAfterFirst(first.TotalReportCount, pageSize)) |
| | | 150 | | { |
| | 0 | 151 | | if (merged.Reports.Count >= limit) |
| | | 152 | | break; |
| | | 153 | | |
| | 0 | 154 | | var chunk = await GetPageAsync(article, page, pageSize, cancellationToken); |
| | 0 | 155 | | merged.Reports.AddRange(chunk.Reports); |
| | 0 | 156 | | BitrixStockArticleHistoryPaging.ApplyReportLimit(merged, limit); |
| | | 157 | | } |
| | | 158 | | |
| | 0 | 159 | | merged.ExportTruncated = merged.TotalReportCount > merged.Reports.Count; |
| | 0 | 160 | | cache.Set(cacheKey, merged, CacheTtl); |
| | 0 | 161 | | return merged; |
| | 3 | 162 | | } |
| | | 163 | | |
| | | 164 | | private static async Task<int> CountReportsAsync( |
| | | 165 | | System.Data.Common.DbConnection conn, |
| | | 166 | | IReadOnlyList<string> articles, |
| | | 167 | | CancellationToken ct) |
| | | 168 | | { |
| | 0 | 169 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 170 | | cmd.CommandText = $@" |
| | 0 | 171 | | SELECT COUNT(DISTINCT r.ReportId) |
| | 0 | 172 | | FROM BitrixStockSyncReports r |
| | 0 | 173 | | INNER JOIN BitrixStockSyncReportLines l |
| | 0 | 174 | | ON l.ReportId = r.ReportId AND l.Article IN ({ArticleInClause(articles)}) |
| | 0 | 175 | | WHERE r.Status = @status;"; |
| | 0 | 176 | | AddArticleParams(cmd, articles); |
| | 0 | 177 | | AddParam(cmd, "@status", CompletedStatus); |
| | 0 | 178 | | return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct) ?? 0); |
| | 0 | 179 | | } |
| | | 180 | | |
| | 0 | 181 | | private sealed record GlobalColumnFlags(bool HasPrices, bool HasPushes, bool IsMultiClient); |
| | | 182 | | |
| | | 183 | | private async Task<GlobalColumnFlags> LoadGlobalColumnFlagsAsync( |
| | | 184 | | System.Data.Common.DbConnection conn, |
| | | 185 | | IReadOnlyList<string> articles, |
| | | 186 | | CancellationToken ct) |
| | | 187 | | { |
| | 0 | 188 | | var cacheKey = $"bitrix-art-hist-flags:{BitrixStockArticleVariants.Normalize(articles[0])}"; |
| | 0 | 189 | | if (cache.TryGetValue(cacheKey, out GlobalColumnFlags? cached) && cached != null) |
| | 0 | 190 | | return cached; |
| | | 191 | | |
| | 0 | 192 | | var inClause = ArticleInClause(articles); |
| | 0 | 193 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 194 | | cmd.CommandText = $@" |
| | 0 | 195 | | SELECT |
| | 0 | 196 | | EXISTS ( |
| | 0 | 197 | | SELECT 1 FROM BitrixStockSyncReportOzonPrices p |
| | 0 | 198 | | INNER JOIN BitrixStockSyncReports r ON r.ReportId = p.ReportId AND r.Status = @status |
| | 0 | 199 | | WHERE p.Article IN ({inClause}) |
| | 0 | 200 | | AND p.PriceDataJson IS NOT NULL AND p.PriceDataJson != '' |
| | 0 | 201 | | ) AS HasPrices, |
| | 0 | 202 | | EXISTS ( |
| | 0 | 203 | | SELECT 1 FROM BitrixStockSyncReportOzonPrices p |
| | 0 | 204 | | INNER JOIN BitrixStockSyncReports r ON r.ReportId = p.ReportId AND r.Status = @status |
| | 0 | 205 | | WHERE p.Article IN ({inClause}) |
| | 0 | 206 | | AND (p.PricePushedNew IS NOT NULL OR (p.PricePushStatus IS NOT NULL AND p.PricePushStatus != '')) |
| | 0 | 207 | | ) AS HasPushes, |
| | 0 | 208 | | EXISTS ( |
| | 0 | 209 | | SELECT 1 FROM BitrixStockSyncReports r |
| | 0 | 210 | | WHERE r.Status = @status |
| | 0 | 211 | | AND r.MultiClientIdsJson IS NOT NULL |
| | 0 | 212 | | AND r.MultiClientIdsJson != '[]' |
| | 0 | 213 | | AND EXISTS ( |
| | 0 | 214 | | SELECT 1 FROM BitrixStockSyncReportLines l |
| | 0 | 215 | | WHERE l.ReportId = r.ReportId AND l.Article IN ({inClause}) |
| | 0 | 216 | | ) |
| | 0 | 217 | | ) AS IsMultiClient;"; |
| | 0 | 218 | | AddArticleParams(cmd, articles); |
| | 0 | 219 | | AddParam(cmd, "@status", CompletedStatus); |
| | | 220 | | |
| | 0 | 221 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 222 | | var flags = new GlobalColumnFlags(false, false, false); |
| | 0 | 223 | | if (await reader.ReadAsync(ct)) |
| | | 224 | | { |
| | 0 | 225 | | flags = new GlobalColumnFlags( |
| | 0 | 226 | | ReadBool(reader, 0), |
| | 0 | 227 | | ReadBool(reader, 1), |
| | 0 | 228 | | ReadBool(reader, 2)); |
| | | 229 | | } |
| | | 230 | | |
| | 0 | 231 | | cache.Set(cacheKey, flags, CacheTtl); |
| | 0 | 232 | | return flags; |
| | 0 | 233 | | } |
| | | 234 | | |
| | 0 | 235 | | private sealed record ReportMetaRow( |
| | 0 | 236 | | string ReportId, |
| | 0 | 237 | | DateTime GeneratedAtUtc, |
| | 0 | 238 | | int OzonClientId, |
| | 0 | 239 | | string OzonClientName, |
| | 0 | 240 | | List<int> MultiClientIds); |
| | | 241 | | |
| | | 242 | | private static async Task<List<ReportMetaRow>> LoadReportMetasAsync( |
| | | 243 | | System.Data.Common.DbConnection conn, |
| | | 244 | | IReadOnlyList<string> articles, |
| | | 245 | | int page, |
| | | 246 | | int pageSize, |
| | | 247 | | CancellationToken ct) |
| | | 248 | | { |
| | 0 | 249 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 250 | | cmd.CommandText = $@" |
| | 0 | 251 | | SELECT r.ReportId, r.GeneratedAtUtc, r.OzonClientId, r.OzonClientName, |
| | 0 | 252 | | COALESCE(r.MultiClientIdsJson, '[]') |
| | 0 | 253 | | FROM BitrixStockSyncReports r |
| | 0 | 254 | | WHERE r.Status = @status |
| | 0 | 255 | | AND EXISTS ( |
| | 0 | 256 | | SELECT 1 FROM BitrixStockSyncReportLines l |
| | 0 | 257 | | WHERE l.ReportId = r.ReportId AND l.Article IN ({ArticleInClause(articles)}) |
| | 0 | 258 | | ) |
| | 0 | 259 | | ORDER BY r.GeneratedAtUtc DESC |
| | 0 | 260 | | LIMIT @take OFFSET @skip;"; |
| | 0 | 261 | | AddArticleParams(cmd, articles); |
| | 0 | 262 | | AddParam(cmd, "@status", CompletedStatus); |
| | 0 | 263 | | AddParam(cmd, "@take", pageSize); |
| | 0 | 264 | | AddParam(cmd, "@skip", (page - 1) * pageSize); |
| | | 265 | | |
| | 0 | 266 | | var list = new List<ReportMetaRow>(); |
| | 0 | 267 | | await using var r = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 268 | | while (await r.ReadAsync(ct)) |
| | | 269 | | { |
| | 0 | 270 | | list.Add(new ReportMetaRow( |
| | 0 | 271 | | r.GetString(0), |
| | 0 | 272 | | r.GetDateTime(1), |
| | 0 | 273 | | r.GetInt32(2), |
| | 0 | 274 | | r.IsDBNull(3) ? "" : r.GetString(3), |
| | 0 | 275 | | ParseMultiClientIds(r.IsDBNull(4) ? "[]" : r.GetString(4)))); |
| | | 276 | | } |
| | 0 | 277 | | return list; |
| | 0 | 278 | | } |
| | | 279 | | |
| | | 280 | | private static async Task<Dictionary<string, List<BitrixStockSyncReportWarehouseLineDto>>> LoadLinesAsync( |
| | | 281 | | System.Data.Common.DbConnection conn, |
| | | 282 | | IReadOnlyList<string> articles, |
| | | 283 | | List<string> reportIds, |
| | | 284 | | CancellationToken ct) |
| | | 285 | | { |
| | 0 | 286 | | var result = new Dictionary<string, List<BitrixStockSyncReportWarehouseLineDto>>(StringComparer.OrdinalIgnoreCas |
| | 0 | 287 | | if (reportIds.Count == 0) return result; |
| | | 288 | | |
| | 0 | 289 | | var inClause = string.Join(",", reportIds.Select((_, i) => $"@rid{i}")); |
| | 0 | 290 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 291 | | cmd.CommandText = $@" |
| | 0 | 292 | | SELECT l.ReportId, l.Id, l.ProductId, l.Article, l.OneCStore, l.OzonWarehouseName, l.OzonWarehouseId, l.MappingDescripti |
| | 0 | 293 | | l.OneCQty, l.OzonQty, l.Delta, l.Action, l.ActionLabel, l.ActionIcon, l.RowClass, l.Note, l.OzonProductId, l.OfferId, |
| | 0 | 294 | | l.OzonApplyStatus, l.OzonAppliedUtc, l.OzonApplyError, l.OzonQtyAfter, |
| | 0 | 295 | | sm.ManufacturerName, |
| | 0 | 296 | | l.OneCPrice, l.PrevOneCPrice, |
| | 0 | 297 | | l.IsArchived, l.IsAutoArchived, |
| | 0 | 298 | | COALESCE(l.OzonClientId, 0) AS OzonClientId, COALESCE(l.OzonClientName, '') AS OzonClientName, |
| | 0 | 299 | | l.OzonCleanupStatus, l.OzonCleanupAtUtc, l.OzonCleanupError, |
| | 0 | 300 | | COALESCE(l.InPromo, 0) AS InPromo, l.PromoName, |
| | 0 | 301 | | COALESCE(l.IsOzonOrphan, 0) AS IsOzonOrphan, l.OzonOrphanStatus |
| | 0 | 302 | | FROM BitrixStockSyncReportLines l |
| | 0 | 303 | | LEFT JOIN ( |
| | 0 | 304 | | SELECT Article, MIN(Manufacturer) AS ManufacturerName |
| | 0 | 305 | | FROM WarehouseStockSnapshot |
| | 0 | 306 | | WHERE Manufacturer IS NOT NULL AND TRIM(Manufacturer) != '' |
| | 0 | 307 | | GROUP BY Article |
| | 0 | 308 | | ) sm ON sm.Article = l.Article |
| | 0 | 309 | | WHERE l.Article IN ({ArticleInClause(articles)}) |
| | 0 | 310 | | AND l.ReportId IN ({inClause}) |
| | 0 | 311 | | ORDER BY l.ReportId, l.OneCStore, l.OzonWarehouseName, l.OzonClientId;"; |
| | 0 | 312 | | AddArticleParams(cmd, articles); |
| | 0 | 313 | | for (var i = 0; i < reportIds.Count; i++) |
| | 0 | 314 | | AddParam(cmd, $"@rid{i}", reportIds[i]); |
| | | 315 | | |
| | 0 | 316 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 317 | | while (await reader.ReadAsync(ct)) |
| | | 318 | | { |
| | 0 | 319 | | var reportId = reader.GetString(0); |
| | 0 | 320 | | var line = new BitrixStockSyncReportWarehouseLineDto |
| | 0 | 321 | | { |
| | 0 | 322 | | LineId = reader.GetInt64(1), |
| | 0 | 323 | | ProductId = reader.GetInt32(2), |
| | 0 | 324 | | Article = reader.GetString(3), |
| | 0 | 325 | | OneCStore = reader.GetString(4), |
| | 0 | 326 | | OzonWarehouseName = reader.GetString(5), |
| | 0 | 327 | | OzonWarehouseId = reader.GetInt64(6), |
| | 0 | 328 | | MappingDescription = reader.GetString(7), |
| | 0 | 329 | | OneCQty = reader.GetInt32(8), |
| | 0 | 330 | | OzonQty = reader.GetInt32(9), |
| | 0 | 331 | | Delta = reader.GetInt32(10), |
| | 0 | 332 | | Action = reader.GetString(11), |
| | 0 | 333 | | ActionLabel = reader.GetString(12), |
| | 0 | 334 | | ActionIcon = reader.GetString(13), |
| | 0 | 335 | | RowClass = reader.GetString(14), |
| | 0 | 336 | | Note = reader.IsDBNull(15) ? null : reader.GetString(15), |
| | 0 | 337 | | OzonProductId = reader.IsDBNull(16) ? null : reader.GetInt64(16), |
| | 0 | 338 | | OfferId = reader.IsDBNull(17) ? null : reader.GetString(17), |
| | 0 | 339 | | OzonApplyStatus = reader.IsDBNull(18) ? null : reader.GetString(18), |
| | 0 | 340 | | OzonAppliedUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19), |
| | 0 | 341 | | OzonApplyError = reader.IsDBNull(20) ? null : reader.GetString(20), |
| | 0 | 342 | | OzonQtyAfter = reader.IsDBNull(21) ? null : reader.GetInt32(21), |
| | 0 | 343 | | ManufacturerName = reader.IsDBNull(22) ? null : reader.GetString(22), |
| | 0 | 344 | | OneCPrice = reader.IsDBNull(23) ? null : Convert.ToDecimal(reader.GetValue(23)), |
| | 0 | 345 | | PrevOneCPrice = reader.IsDBNull(24) ? null : Convert.ToDecimal(reader.GetValue(24)), |
| | 0 | 346 | | IsArchived = !reader.IsDBNull(25) && reader.GetInt32(25) != 0, |
| | 0 | 347 | | IsAutoArchived = !reader.IsDBNull(26) && reader.GetInt32(26) != 0, |
| | 0 | 348 | | OzonClientId = reader.IsDBNull(27) ? 0 : reader.GetInt32(27), |
| | 0 | 349 | | OzonClientName = reader.IsDBNull(28) ? "" : reader.GetString(28), |
| | 0 | 350 | | OzonCleanupStatus = reader.IsDBNull(29) ? null : reader.GetString(29), |
| | 0 | 351 | | OzonCleanupAtUtc = reader.IsDBNull(30) ? null : reader.GetDateTime(30), |
| | 0 | 352 | | OzonCleanupError = reader.IsDBNull(31) ? null : reader.GetString(31), |
| | 0 | 353 | | InPromo = !reader.IsDBNull(32) && reader.GetInt32(32) != 0, |
| | 0 | 354 | | PromoName = reader.IsDBNull(33) ? null : reader.GetString(33), |
| | 0 | 355 | | IsOzonOrphan = !reader.IsDBNull(34) && reader.GetInt32(34) != 0, |
| | 0 | 356 | | OzonOrphanStatus = reader.IsDBNull(35) ? null : reader.GetString(35) |
| | 0 | 357 | | }; |
| | 0 | 358 | | if (!result.TryGetValue(reportId, out var rows)) |
| | | 359 | | { |
| | 0 | 360 | | rows = new List<BitrixStockSyncReportWarehouseLineDto>(); |
| | 0 | 361 | | result[reportId] = rows; |
| | | 362 | | } |
| | 0 | 363 | | rows.Add(line); |
| | | 364 | | } |
| | 0 | 365 | | return result; |
| | 0 | 366 | | } |
| | | 367 | | |
| | | 368 | | private async Task<Dictionary<string, Dictionary<int, OzonPriceInfoDto>>> LoadPricesAsync( |
| | | 369 | | System.Data.Common.DbConnection conn, |
| | | 370 | | IReadOnlyList<string> articles, |
| | | 371 | | List<string> reportIds, |
| | | 372 | | CancellationToken ct) |
| | | 373 | | { |
| | 0 | 374 | | var result = new Dictionary<string, Dictionary<int, OzonPriceInfoDto>>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 375 | | if (reportIds.Count == 0) return result; |
| | | 376 | | |
| | 0 | 377 | | var inClause = string.Join(",", reportIds.Select((_, i) => $"@prid{i}")); |
| | 0 | 378 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 379 | | cmd.CommandText = $@" |
| | 0 | 380 | | SELECT ReportId, Article, OzonClientId, PriceDataJson, |
| | 0 | 381 | | PricePushedOld, PricePushedNew, PricePushedAtUtc, PushedMarkupPct, |
| | 0 | 382 | | PushedColorIndexBefore, PushedColorIndexAfter, PushedCompetitorPrice, |
| | 0 | 383 | | PricePushStatus, PricePushError |
| | 0 | 384 | | FROM BitrixStockSyncReportOzonPrices |
| | 0 | 385 | | WHERE Article IN ({ArticleInClause(articles)}) |
| | 0 | 386 | | AND ReportId IN ({inClause});"; |
| | 0 | 387 | | AddArticleParams(cmd, articles); |
| | 0 | 388 | | for (var i = 0; i < reportIds.Count; i++) |
| | 0 | 389 | | AddParam(cmd, $"@prid{i}", reportIds[i]); |
| | | 390 | | |
| | 0 | 391 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 392 | | while (await reader.ReadAsync(ct)) |
| | | 393 | | { |
| | 0 | 394 | | var reportId = reader.GetString(0); |
| | 0 | 395 | | var dto = ParsePriceRow(reader, reportId); |
| | 0 | 396 | | if (dto == null) continue; |
| | 0 | 397 | | if (!result.TryGetValue(reportId, out var map)) |
| | | 398 | | { |
| | 0 | 399 | | map = new Dictionary<int, OzonPriceInfoDto>(); |
| | 0 | 400 | | result[reportId] = map; |
| | | 401 | | } |
| | 0 | 402 | | map[dto.OzonClientId] = dto; |
| | | 403 | | } |
| | 0 | 404 | | return result; |
| | 0 | 405 | | } |
| | | 406 | | |
| | | 407 | | private OzonPriceInfoDto? ParsePriceRow(System.Data.Common.DbDataReader reader, string reportId) |
| | | 408 | | { |
| | | 409 | | try |
| | | 410 | | { |
| | 0 | 411 | | var json = reader.IsDBNull(3) ? null : reader.GetString(3); |
| | 0 | 412 | | if (string.IsNullOrEmpty(json)) return null; |
| | 0 | 413 | | var item = JObject.Parse(json); |
| | 0 | 414 | | var priceObj = item["price"] as JObject ?? item; |
| | 0 | 415 | | var indexesObj = item["price_indexes"] as JObject; |
| | 0 | 416 | | var truePrice = priceObj["price"]?.Value<decimal?>() |
| | 0 | 417 | | ?? priceObj["customer_price"]?.Value<decimal?>() |
| | 0 | 418 | | ?? priceObj["true_price"]?.Value<decimal?>(); |
| | 0 | 419 | | var sellingPrice = priceObj["marketing_seller_price"]?.Value<decimal?>() |
| | 0 | 420 | | ?? priceObj["price"]?.Value<decimal?>(); |
| | 0 | 421 | | var colorIndex = indexesObj?["color_index"]?.Value<string>(); |
| | | 422 | | |
| | 0 | 423 | | return new OzonPriceInfoDto |
| | 0 | 424 | | { |
| | 0 | 425 | | Article = reader.GetString(1), |
| | 0 | 426 | | OzonClientId = reader.GetInt32(2), |
| | 0 | 427 | | TruePrice = truePrice, |
| | 0 | 428 | | SellingPrice = sellingPrice, |
| | 0 | 429 | | ColorIndex = colorIndex, |
| | 0 | 430 | | PricePushedOld = reader.IsDBNull(4) ? null : reader.GetDecimal(4), |
| | 0 | 431 | | PricePushedNew = reader.IsDBNull(5) ? null : reader.GetDecimal(5), |
| | 0 | 432 | | PricePushedAtUtc = reader.IsDBNull(6) ? null : reader.GetDateTime(6), |
| | 0 | 433 | | PushedMarkupPct = reader.IsDBNull(7) ? null : reader.GetDecimal(7), |
| | 0 | 434 | | PushedColorIndexBefore = reader.IsDBNull(8) ? null : reader.GetString(8), |
| | 0 | 435 | | PushedColorIndexAfter = reader.IsDBNull(9) ? null : reader.GetString(9), |
| | 0 | 436 | | PushedCompetitorPrice = reader.IsDBNull(10) ? null : reader.GetDecimal(10), |
| | 0 | 437 | | PricePushStatus = reader.IsDBNull(11) ? null : reader.GetString(11), |
| | 0 | 438 | | PricePushError = reader.IsDBNull(12) ? null : reader.GetString(12), |
| | 0 | 439 | | }; |
| | | 440 | | } |
| | 0 | 441 | | catch (Exception ex) |
| | | 442 | | { |
| | 0 | 443 | | logger.LogWarning(ex, |
| | 0 | 444 | | "BitrixStock article history: failed to parse Ozon price JSON for report {ReportId}, article {Article}, |
| | 0 | 445 | | reportId, |
| | 0 | 446 | | reader.IsDBNull(1) ? "" : reader.GetString(1), |
| | 0 | 447 | | reader.IsDBNull(2) ? 0 : reader.GetInt32(2)); |
| | 0 | 448 | | return null; |
| | | 449 | | } |
| | 0 | 450 | | } |
| | | 451 | | |
| | | 452 | | private static List<int> ParseMultiClientIds(string json) => |
| | 0 | 453 | | BitrixStockMultiClientParser.ParseMultiClientIds(json); |
| | | 454 | | |
| | | 455 | | private static void AddParam(System.Data.Common.DbCommand cmd, string name, object? value) |
| | | 456 | | { |
| | 0 | 457 | | var p = cmd.CreateParameter(); |
| | 0 | 458 | | p.ParameterName = name; |
| | 0 | 459 | | p.Value = value ?? DBNull.Value; |
| | 0 | 460 | | cmd.Parameters.Add(p); |
| | 0 | 461 | | } |
| | | 462 | | |
| | | 463 | | /// <summary>Список параметров для <c>Article IN (…)</c> — все написания артикула.</summary> |
| | | 464 | | // Без COLLATE: колонки Article в BitrixStockSyncReportLines, BitrixStockSyncReportOzonPrices |
| | | 465 | | // и WarehouseStockSnapshot уже объявлены как utf8mb4_unicode_ci, а явный COLLATE на колонке |
| | | 466 | | // делает предикат несаргабельным — индекс по Article перестаёт использоваться и запрос |
| | | 467 | | // уходит в полный скан (COUNT по истории упирался в CommandTimeout). |
| | | 468 | | private static string ArticleInClause(IReadOnlyList<string> articles) => |
| | 0 | 469 | | string.Join(",", articles.Select((_, i) => $"@art{i}")); |
| | | 470 | | |
| | | 471 | | private static void AddArticleParams(System.Data.Common.DbCommand cmd, IReadOnlyList<string> articles) |
| | | 472 | | { |
| | 0 | 473 | | for (var i = 0; i < articles.Count; i++) |
| | 0 | 474 | | AddParam(cmd, $"@art{i}", articles[i]); |
| | 0 | 475 | | } |
| | | 476 | | |
| | | 477 | | /// <summary> |
| | | 478 | | /// Все написания артикула, встречающиеся в базе: «8934833060A0» и «89348-33060-A0» — одна и та же |
| | | 479 | | /// деталь, но в 1С и в Ozon это разные карточки, и строки отчёта пишутся под тем написанием, |
| | | 480 | | /// которое пришло из источника. |
| | | 481 | | /// Кандидаты берутся из <c>WarehouseStockSnapshot</c> (таблица маленькая, скан дешёвый) и из |
| | | 482 | | /// <c>BitrixStockSyncReportLines</c> префиксным range-scan'ом по индексу Article — полный скан |
| | | 483 | | /// 2,7 млн строк с REPLACE() здесь недопустим. |
| | | 484 | | /// </summary> |
| | | 485 | | private static async Task<List<string>> ResolveArticleVariantsAsync( |
| | | 486 | | System.Data.Common.DbConnection conn, |
| | | 487 | | string article, |
| | | 488 | | CancellationToken ct) |
| | | 489 | | { |
| | 0 | 490 | | var patterns = BitrixStockArticleVariants.BuildPrefixPatterns(article); |
| | 0 | 491 | | if (patterns.Count == 0) |
| | 0 | 492 | | return new List<string> { article }; |
| | | 493 | | |
| | 0 | 494 | | var candidates = new List<string>(); |
| | 0 | 495 | | var likeClause = string.Join(" OR ", patterns.Select((_, i) => $"Article LIKE @pat{i}")); |
| | | 496 | | |
| | 0 | 497 | | await using var cmd = conn.CreateCommand(); |
| | 0 | 498 | | cmd.CommandText = $@" |
| | 0 | 499 | | SELECT DISTINCT Article FROM WarehouseStockSnapshot WHERE {likeClause} |
| | 0 | 500 | | UNION |
| | 0 | 501 | | SELECT DISTINCT Article FROM BitrixStockSyncReportLines WHERE {likeClause};"; |
| | 0 | 502 | | for (var i = 0; i < patterns.Count; i++) |
| | 0 | 503 | | AddParam(cmd, $"@pat{i}", patterns[i]); |
| | | 504 | | |
| | 0 | 505 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | 0 | 506 | | while (await reader.ReadAsync(ct)) |
| | | 507 | | { |
| | 0 | 508 | | if (!reader.IsDBNull(0)) |
| | 0 | 509 | | candidates.Add(reader.GetString(0)); |
| | | 510 | | } |
| | | 511 | | |
| | 0 | 512 | | return BitrixStockArticleVariants.SelectMatching(article, candidates); |
| | 0 | 513 | | } |
| | | 514 | | |
| | | 515 | | private static bool ReadBool(System.Data.Common.DbDataReader reader, int ordinal) |
| | | 516 | | { |
| | 0 | 517 | | if (reader.IsDBNull(ordinal)) |
| | 0 | 518 | | return false; |
| | 0 | 519 | | return reader.GetValue(ordinal) switch |
| | 0 | 520 | | { |
| | 0 | 521 | | bool b => b, |
| | 0 | 522 | | int i => i != 0, |
| | 0 | 523 | | long l => l != 0, |
| | 0 | 524 | | decimal d => d != 0, |
| | 0 | 525 | | _ => Convert.ToBoolean(reader.GetValue(ordinal)) |
| | 0 | 526 | | }; |
| | | 527 | | } |
| | | 528 | | } |