< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.Directories.ProductsController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/Directories/ProductsController.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 237
Coverable lines: 237
Total lines: 548
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 90
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
Index()100%210%
GridRows()0%4260%
GridFacets()0%4260%
Details()0%2040%
Create()100%210%
Create()0%620%
Edit()0%2040%
Edit()0%4260%
Delete()0%2040%
DeleteConfirmed()0%620%
ProductExists(...)100%210%
LinkReplacementProduct()0%600240%
ntProduct()0%156120%
UnlinkReplacementProduct()0%4260%
NormalizeArticle(...)0%620%
HasCircularReference()0%110100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/Directories/ProductsController.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Net;
 5using System.Threading.Tasks;
 6using CsvHelper;
 7using Microsoft.AspNetCore.Authorization;
 8using Microsoft.AspNetCore.Hosting;
 9using Microsoft.AspNetCore.Mvc;
 10using Microsoft.AspNetCore.Mvc.Rendering;
 11using Microsoft.EntityFrameworkCore;
 12using Microsoft.Extensions.Caching.Memory;
 13using Newtonsoft.Json;
 14using OzonDomains.Models;
 15using OzonRepositories.Context;
 16using OzonDomains.Models.Authorization.Enums;
 17using OzonOrdersWeb.Authorization;
 18using OzonOrdersWeb.WebServices.Directories;
 19using OzonOrdersWeb.WebServices.Grid;
 20using Services.CacheServices.Cache;
 21using Services.CacheServices.Cache.OzonOrdersCache;
 22
 23
 24namespace OzonOrdersWeb.Areas.Studio2.Controllers.Directories
 25{
 26    [Area("Studio2")]
 27    [Authorize(Roles = "User,Admin")]
 28    public class ProductsController : Controller
 29    {
 30        private readonly ApplicationDbContext _context;
 31        private readonly ProductCache _cache;
 32        private readonly IMemoryCache _memoryCache;
 33
 34        private const string PRODUCT_CACHE_KEY = "products_data";
 35        private const string REPLACEMENT_CACHE_PREFIX = "replacement_";
 36
 037        private readonly MemoryCacheEntryOptions _cacheOptions = new MemoryCacheEntryOptions()
 038            .SetSlidingExpiration(TimeSpan.FromMinutes(5)) // Скользящее время жизни 5 минут
 039            .SetAbsoluteExpiration(TimeSpan.FromHours(1)) // Абсолютное время жизни 1 час
 040            .SetPriority(CacheItemPriority.Normal);
 41
 42
 043        public ProductsController(ApplicationDbContext context,
 044            ProductCache cache,
 045            IMemoryCache memoryCache)
 46        {
 047            _context = context;
 048            _cache = cache;
 049            _memoryCache = memoryCache;
 050        }
 51
 52        private const string PageCode = "Studio2.Products.Index";
 53
 54        /// <summary>Каркас страницы: строки (~50 тыс.) грид подгружает блоками через <see cref="GridRows"/>.</summary>
 55        public async Task<IActionResult> Index(CancellationToken cancellationToken)
 56        {
 057            ViewBag.TotalCount = await _context.Products.CountAsync(cancellationToken);
 058            return View();
 059        }
 60
 61        [HttpPost("Studio2/Products/grid")]
 62        [ValidateAntiForgeryToken]
 63        [PageAuthorize(PageCode, AccessType.View)]
 64        public async Task<IActionResult> GridRows([FromBody] ServerGridRequest? request, CancellationToken cancellationT
 65        {
 066            var error = ServerGridQuery.Validate(request);
 067            if (error != null) return BadRequest(new { success = false, error });
 68
 069            var def = DirectoryGridDefinitions.Products;
 070            var filtered = ServerGridQuery.ApplyFilters(_context.Products.AsNoTracking(), def, request!);
 071            var total = await filtered.CountAsync(cancellationToken);
 072            var start = Math.Max(0, request!.StartRow);
 073            var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize);
 074            var rows = start >= total
 075                ? []
 076                : await ServerGridQuery.ApplySort(filtered, def, request.SortModel).Skip(start).Take(take)
 077                    .Select(p => new
 078                    {
 079                        p.Id, p.Article, p.OzonProductId, p.FboOzonSkuId, p.FbsOzonSkuId, p.CommercialCategory, p.Barcod
 080                        p.Volume, p.VolumetricWeight, p.Weight, p.CurrentPriceWithDiscount, p.ImageUrl,
 081                        ReplacementArticle = p.ReplacementProduct != null ? p.ReplacementProduct.Article : null
 082                    })
 083                    .ToListAsync(cancellationToken);
 084            var lastRow = start + rows.Count >= total ? total : -1;
 085            return Ok(new { success = true, rows, totalCount = total, lastRow });
 086        }
 87
 88        [HttpPost("Studio2/Products/grid/facets")]
 89        [ValidateAntiForgeryToken]
 90        [PageAuthorize(PageCode, AccessType.View)]
 91        public async Task<IActionResult> GridFacets([FromBody] ServerGridRequest? request, CancellationToken cancellatio
 92        {
 093            if (request is null || string.IsNullOrWhiteSpace(request.ColId)) return BadRequest(new { success = false, er
 094            request.StartRow = 0;
 095            request.EndRow = 1;
 096            var error = ServerGridQuery.Validate(request);
 097            if (error != null) return BadRequest(new { success = false, error });
 098            var values = await ServerGridQuery.FacetsAsync(_context.Products.AsNoTracking(), DirectoryGridDefinitions.Pr
 099            return Ok(new { success = true, values });
 0100        }
 101
 102        // GET: Products/Details/5
 103        public async Task<IActionResult> Details(int? id)
 104        {
 0105            if (id == null)
 106            {
 0107                return NotFound();
 108            }
 109
 0110            var product = await _context.Products
 0111                .FirstOrDefaultAsync(m => m.Id == id);
 0112            if (product == null)
 113            {
 0114                return NotFound();
 115            }
 116
 0117            return View(product);
 0118        }
 119
 120        // GET: Products/Create
 121        public IActionResult Create()
 122        {
 0123            return View();
 124        }
 125
 126
 127        [HttpPost]
 128        [ValidateAntiForgeryToken]
 129        public async Task<IActionResult> Create(
 130            [Bind(
 131                "Id,Article,OzonProductId,FboOzonSkuId,FbsOzonSkuId,CommercialCategory,Volume,VolumetricWeight,CurrentPr
 132            Product product)
 133        {
 0134            if (ModelState.IsValid)
 135            {
 0136                _context.Add(product);
 0137                await _context.SaveChangesAsync();
 0138                await _cache.Set();
 0139                return RedirectToAction(nameof(Index));
 140            }
 141
 0142            return View(product);
 0143        }
 144
 145        // GET: Products/Edit/5
 146        public async Task<IActionResult> Edit(int? id)
 147        {
 0148            if (id == null)
 149            {
 0150                return NotFound();
 151            }
 152
 0153            var product = await _context.Products.FindAsync(id);
 0154            if (product == null)
 155            {
 0156                return NotFound();
 157            }
 158
 0159            return View(product);
 0160        }
 161
 162        // POST: Products/Edit/5
 163        // To protect from overposting attacks, enable the specific properties you want to bind to.
 164        // For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
 165        [HttpPost]
 166        [ValidateAntiForgeryToken]
 167        public async Task<IActionResult> Edit(int id,
 168            [Bind(
 169                "Id,Article,OzonProductId,FboOzonSkuId,FbsOzonSkuId,CommercialCategory,Volume,VolumetricWeight,CurrentPr
 170            Product product)
 171        {
 0172            if (id != product.Id)
 173            {
 0174                return NotFound();
 175            }
 176
 0177            if (ModelState.IsValid)
 178            {
 179                try
 180                {
 0181                    _context.Update(product);
 0182                    await _context.SaveChangesAsync();
 0183                    await _cache.Set();
 0184                }
 0185                catch (DbUpdateConcurrencyException)
 186                {
 0187                    if (!ProductExists(product.Id))
 188                    {
 0189                        return NotFound();
 190                    }
 191                    else
 192                    {
 0193                        throw;
 194                    }
 195                }
 196
 0197                return RedirectToAction(nameof(Index));
 198            }
 199
 0200            return View(product);
 0201        }
 202
 203        // GET: Products/Delete/5
 204        public async Task<IActionResult> Delete(int? id)
 205        {
 0206            if (id == null)
 207            {
 0208                return NotFound();
 209            }
 210
 0211            var product = await _context.Products
 0212                .FirstOrDefaultAsync(m => m.Id == id);
 0213            if (product == null)
 214            {
 0215                return NotFound();
 216            }
 217
 0218            return View(product);
 0219        }
 220
 221        // POST: Products/Delete/5
 222        [HttpPost, ActionName("Delete")]
 223        [ValidateAntiForgeryToken]
 224        public async Task<IActionResult> DeleteConfirmed(int id)
 225        {
 0226            var product = await _context.Products.FindAsync(id);
 0227            if (product != null)
 228            {
 0229                _context.Products.Remove(product);
 230            }
 231
 0232            await _context.SaveChangesAsync();
 0233            await _cache.Set();
 0234            return RedirectToAction(nameof(Index));
 0235        }
 236
 237        private bool ProductExists(int id)
 238        {
 0239            return _context.Products.Any(e => e.Id == id);
 240        }
 241
 242        /// <summary>
 243        /// Связывает продукт с продуктом-заменой по артикулу.
 244        /// Если продукт с указанным артикулом не найден, создается новый продукт.
 245        /// </summary>
 246        /// <param name="originalProductId">ID оригинального продукта</param>
 247        /// <param name="replacementArticle">Артикул продукта-замены</param>
 248        /// <returns>JSON результат операции</returns>
 249        [HttpPost("LinkReplacementProduct")]
 250        [Authorize(Roles = "Admin")]
 251        public async Task<IActionResult> LinkReplacementProduct(int originalProductId, string replacementArticle)
 252        {
 253            try
 254            {
 255                // Валидация входных данных
 0256                if (originalProductId <= 0)
 257                {
 0258                    return Json(new { success = false, message = "Неверный ID оригинального продукта" });
 259                }
 260
 261                // Проверяем существование оригинального продукта
 0262                var originalProduct = await _context.Products
 0263                    .FirstOrDefaultAsync(p => p.Id == originalProductId);
 264
 0265                if (originalProduct == null)
 266                {
 0267                    return Json(new { success = false, message = $"Продукт с ID {originalProductId} не найден" });
 268                }
 269
 270                // Пустой артикул — отвязываем связь с продуктом-заменой
 0271                if (string.IsNullOrWhiteSpace(replacementArticle))
 272                {
 0273                    if (originalProduct.ReplacementProductId == null)
 274                    {
 0275                        return Json(new
 0276                            { success = true, message = "У продукта нет связи с продуктом-заменой", unlinked = true });
 277                    }
 278
 0279                    originalProduct.ReplacementProductId = null;
 0280                    await _context.SaveChangesAsync();
 281
 282                    // Инвалидация кэша
 0283                    _memoryCache.Remove(PRODUCT_CACHE_KEY);
 0284                    _memoryCache.Remove($"{REPLACEMENT_CACHE_PREFIX}{originalProductId}");
 285
 0286                    return Json(new
 0287                    {
 0288                        success = true,
 0289                        message = "Связь с продуктом-заменой отвязана",
 0290                        unlinked = true,
 0291                        replacementProductId = (int?)null,
 0292                        replacementArticle = (string?)null
 0293                    });
 294                }
 295
 296                // Проверяем, что продукт не ссылается сам на себя
 0297                if (originalProduct.Article != null &&
 0298                    NormalizeArticle(originalProduct.Article) == NormalizeArticle(replacementArticle))
 299                {
 0300                    return Json(new { success = false, message = "Продукт не может ссылаться сам на себя" });
 301                }
 302
 303                // Нормализуем артикул для поиска
 0304                var normalizedReplacementArticle = NormalizeArticle(replacementArticle);
 305
 306                // Пробуем найти в кэше список продуктов
 0307                if (!_memoryCache.TryGetValue(PRODUCT_CACHE_KEY, out List<Product>? allProducts) || allProducts == null)
 308                {
 309                    // Если в кэше нет, загружаем из базы
 0310                    allProducts = await _context.Products
 0311                        .Where(p => p.Article != null)
 0312                        .ToListAsync();
 313
 314                    // Кэшируем
 0315                    _memoryCache.Set(PRODUCT_CACHE_KEY, allProducts, _cacheOptions);
 316                }
 317
 318                // Ищем продукт-замену по нормализованному артикулу (case-insensitive)
 0319                var replacementProduct = allProducts
 0320                    .FirstOrDefault(p => p.Article != null && NormalizeArticle(p.Article) == normalizedReplacementArticl
 321
 322                // Флаг для отслеживания, был ли создан новый продукт
 0323                bool wasCreated = false;
 324
 325                // Если продукт не найден, создаем новый
 0326                if (replacementProduct == null)
 327                {
 0328                    replacementProduct = new Product
 0329                    {
 0330                        Article = replacementArticle.Trim(),
 0331                        // Остальные поля можно заполнить позже или оставить null
 0332                    };
 333
 0334                    _context.Products.Add(replacementProduct);
 0335                    await _context.SaveChangesAsync();
 0336                    wasCreated = true;
 337
 338                    // Инвалидация кэша продуктов
 0339                    _memoryCache.Remove(PRODUCT_CACHE_KEY);
 340                    // Инвалидация кэша для оригинального продукта
 0341                    _memoryCache.Remove($"{REPLACEMENT_CACHE_PREFIX}{originalProductId}");
 342                }
 343
 344                // Проверяем на циклические ссылки
 0345                if (await HasCircularReference(replacementProduct.Id, originalProductId))
 346                {
 0347                    return Json(new
 0348                    {
 0349                        success = false, message = "Обнаружена циклическая ссылка. Связь не может быть установлена."
 0350                    });
 351                }
 352
 353                // Устанавливаем связь
 0354                originalProduct.ReplacementProductId = replacementProduct.Id;
 355
 356                // Сохраняем изменения
 0357                await _context.SaveChangesAsync();
 358
 359                // Инвалидация кэша
 0360                _memoryCache.Remove(PRODUCT_CACHE_KEY);
 0361                _memoryCache.Remove($"{REPLACEMENT_CACHE_PREFIX}{originalProductId}");
 0362                if (replacementProduct.Id != originalProductId)
 363                {
 0364                    _memoryCache.Remove($"{REPLACEMENT_CACHE_PREFIX}{replacementProduct.Id}");
 365                }
 366
 0367                return Json(new
 0368                {
 0369                    success = true,
 0370                    message = wasCreated
 0371                        ? $"Создан новый продукт и связан с оригинальным (ID: {replacementProduct.Id}, Артикул: {replace
 0372                        : $"Продукт успешно связан с существующим продуктом-заменой (ID: {replacementProduct.Id}, Артику
 0373                    replacementProductId = replacementProduct.Id,
 0374                    replacementArticle = replacementProduct.Article,
 0375                    wasCreated = wasCreated
 0376                });
 377            }
 0378            catch (Exception ex)
 379            {
 0380                return Json(new { success = false, message = $"Ошибка при связывании продуктов: {ex.Message}" });
 381            }
 0382        }
 383
 384        /// <summary>
 385        /// Получает информацию о продукте-замене для указанного продукта
 386        /// </summary>
 387        /// <param name="productId">ID продукта</param>
 388        /// <returns>JSON с информацией о продукте-замене</returns>
 389        [HttpGet]
 390        public async Task<IActionResult> ntProduct(int productId)
 391        {
 392            try
 393            {
 0394                if (productId <= 0)
 395                {
 0396                    return Json(new { success = false, message = "Неверный ID продукта" });
 397                }
 398
 399                // Пробуем получить из кэша
 0400                var cacheKey = $"{REPLACEMENT_CACHE_PREFIX}{productId}";
 401
 0402                if (_memoryCache.TryGetValue(cacheKey, out string? cachedJson) && cachedJson != null)
 403                {
 0404                    return Content(cachedJson, "application/json");
 405                }
 406
 0407                var product = await _context.Products
 0408                    .Include(p => p.ReplacementProduct)
 0409                    .FirstOrDefaultAsync(p => p.Id == productId);
 410
 0411                if (product == null)
 412                {
 0413                    return Json(new { success = false, message = $"Продукт с ID {productId} не найден" });
 414                }
 415
 416                string jsonResult;
 417
 0418                if (product.ReplacementProductId == null || product.ReplacementProduct == null)
 419                {
 0420                    jsonResult = JsonConvert.SerializeObject(new
 0421                    {
 0422                        success = true,
 0423                        hasReplacement = false,
 0424                        message = "У продукта нет связанного продукта-замены"
 0425                    });
 426                }
 427                else
 428                {
 0429                    jsonResult = JsonConvert.SerializeObject(new
 0430                    {
 0431                        success = true,
 0432                        hasReplacement = true,
 0433                        replacementProduct = new
 0434                        {
 0435                            id = product.ReplacementProduct.Id,
 0436                            article = product.ReplacementProduct.Article,
 0437                            commercialCategory = product.ReplacementProduct.CommercialCategory,
 0438                            currentPriceWithDiscount = product.ReplacementProduct.CurrentPriceWithDiscount
 0439                        }
 0440                    });
 441                }
 442
 443                // Кэшируем результат
 0444                _memoryCache.Set(cacheKey, jsonResult, _cacheOptions);
 445
 0446                return Content(jsonResult, "application/json");
 447            }
 0448            catch (Exception ex)
 449            {
 0450                return Json(new
 0451                    { success = false, message = $"Ошибка при получении информации о продукте-замене: {ex.Message}" });
 452            }
 0453        }
 454
 455        /// <summary>
 456        /// Удаляет связь продукта с продуктом-заменой
 457        /// </summary>
 458        /// <param name="productId">ID продукта</param>
 459        /// <returns>JSON результат операции</returns>
 460        [HttpPost]
 461        [Authorize(Roles = "Admin")]
 462        public async Task<IActionResult> UnlinkReplacementProduct(int productId)
 463        {
 464            try
 465            {
 0466                if (productId <= 0)
 467                {
 0468                    return Json(new { success = false, message = "Неверный ID продукта" });
 469                }
 470
 0471                var product = await _context.Products
 0472                    .FirstOrDefaultAsync(p => p.Id == productId);
 473
 0474                if (product == null)
 475                {
 0476                    return Json(new { success = false, message = $"Продукт с ID {productId} не найден" });
 477                }
 478
 0479                if (product.ReplacementProductId == null)
 480                {
 0481                    return Json(new { success = false, message = "У продукта нет связанного продукта-замены" });
 482                }
 483
 0484                product.ReplacementProductId = null;
 0485                await _context.SaveChangesAsync();
 486
 487                // Инвалидация кэша
 0488                _memoryCache.Remove(PRODUCT_CACHE_KEY);
 0489                _memoryCache.Remove($"{REPLACEMENT_CACHE_PREFIX}{productId}");
 490
 0491                return Json(new { success = true, message = "Связь с продуктом-заменой успешно удалена" });
 492            }
 0493            catch (Exception ex)
 494            {
 0495                return Json(new { success = false, message = $"Ошибка при удалении связи: {ex.Message}" });
 496            }
 0497        }
 498
 499        /// <summary>
 500        /// Нормализует артикул: убирает спецсимволы, приводит к нижнему регистру
 501        /// </summary>
 502        private string NormalizeArticle(string article)
 503        {
 0504            if (string.IsNullOrWhiteSpace(article))
 0505                return string.Empty;
 506
 507            // Убираем все символы, кроме букв и цифр, приводим к нижнему регистру
 0508            return new string(article
 0509                .Where(c => char.IsLetterOrDigit(c))
 0510                .Select(c => char.ToLowerInvariant(c))
 0511                .ToArray());
 512        }
 513
 514        /// <summary>
 515        /// Проверяет наличие циклических ссылок в цепочке замен продуктов
 516        /// </summary>
 517        private async Task<bool> HasCircularReference(int startProductId, int targetProductId)
 518        {
 519            // Если startProductId уже равен targetProductId, это циклическая ссылка
 0520            if (startProductId == targetProductId)
 0521                return true;
 522
 523            // Проверяем цепочку замен, чтобы убедиться, что targetProductId не ссылается на startProductId
 0524            var visited = new HashSet<int>();
 0525            var currentId = startProductId;
 526
 0527            while (currentId > 0 && !visited.Contains(currentId))
 528            {
 0529                visited.Add(currentId);
 530
 0531                var product = await _context.Products
 0532                    .Where(p => p.Id == currentId)
 0533                    .Select(p => p.ReplacementProductId)
 0534                    .FirstOrDefaultAsync();
 535
 0536                if (product == null)
 537                    break;
 538
 0539                if (product == targetProductId)
 0540                    return true; // Найдена циклическая ссылка
 541
 0542                currentId = product.Value;
 543            }
 544
 0545            return false;
 0546        }
 547    }
 548}