diff --git a/Webshop.Application/Services/Admin/AdminProductService.cs b/Webshop.Application/Services/Admin/AdminProductService.cs index 510ef47..352cc84 100644 --- a/Webshop.Application/Services/Admin/AdminProductService.cs +++ b/Webshop.Application/Services/Admin/AdminProductService.cs @@ -116,7 +116,7 @@ namespace Webshop.Application.Services.Admin { Console.WriteLine($"---- UPDATE START: Produkt-ID {productDto.Id} ----"); - // 1. Produkt aus der DB laden (inklusive aktueller RowVersion) + // 1. Produkt laden var existingProduct = await _context.Products .Include(p => p.Images) .Include(p => p.Productcategories) @@ -124,62 +124,59 @@ namespace Webshop.Application.Services.Admin if (existingProduct == null) { - return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt mit ID '{productDto.Id}' nicht gefunden."); + return ServiceResult.Fail(ServiceResultType.NotFound, $"Produkt nicht gefunden."); } - // 2. ROBUSTER CONCURRENCY CHECK (Manuell) - // Wir vergleichen die Strings direkt. Das ist sicherer als Byte-Manipulation. + // 2. CONCURRENCY CHECK (Manuell & Optional) + // Wenn RowVersion gesendet wird, prüfen wir sie. Wenn nicht (Workaround), überschreiben wir einfach (Last Win). if (!string.IsNullOrEmpty(productDto.RowVersion)) { - // DB-Wert in String wandeln string dbRowVersion = Convert.ToBase64String(existingProduct.RowVersion ?? new byte[0]); - - // Frontend-Wert bereinigen (Leerzeichen zu Plus, falls URL-Decoded wurde) string incomingRowVersion = productDto.RowVersion.Trim().Replace(" ", "+"); - Console.WriteLine($"DB Version: '{dbRowVersion}'"); - Console.WriteLine($"Frontend Version: '{incomingRowVersion}'"); - + // Einfacher String-Vergleich if (dbRowVersion != incomingRowVersion) { - Console.WriteLine("!!! KONFLIKT: Versionen stimmen nicht überein !!!"); - return ServiceResult.Fail(ServiceResultType.Conflict, "Das Produkt wurde in der Zwischenzeit von jemand anderem bearbeitet. Bitte laden Sie die Seite neu."); + Console.WriteLine("!!! KONFLIKT: Versionen unterschiedlich !!!"); + return ServiceResult.Fail(ServiceResultType.Conflict, "Das Produkt wurde zwischenzeitlich bearbeitet."); } } - // Wenn wir hier sind, ist die Version KORREKT. - // Wir müssen OriginalValue NICHT mehr setzen, da existingProduct ja - // die aktuelle Version aus der DB hat. - // ----------------------------------------------------------------------- - // SCHRITT A: BILDER UPDATE + // SCHRITT A: BILDER UPDATE (ISOLIERT) // ----------------------------------------------------------------------- bool imagesChanged = false; - // Bilder löschen + // A1. Bilder zum Löschen markieren if (productDto.ImagesToDelete != null && productDto.ImagesToDelete.Any()) { var imagesToRemove = existingProduct.Images.Where(img => productDto.ImagesToDelete.Contains(img.Id)).ToList(); if (imagesToRemove.Any()) { + // Wir entfernen sie direkt aus dem Context, nicht nur aus der Liste _context.ProductImages.RemoveRange(imagesToRemove); imagesChanged = true; } } - // Hauptbild ersetzen + // A2. Hauptbild if (productDto.MainImageFile != null) { var existingMainImage = existingProduct.Images.FirstOrDefault(img => img.IsMainImage); - if (existingMainImage != null) _context.ProductImages.Remove(existingMainImage); + if (existingMainImage != null) + { + _context.ProductImages.Remove(existingMainImage); + } await using var stream = productDto.MainImageFile.OpenReadStream(); var url = await _fileStorageService.SaveFileAsync(stream, productDto.MainImageFile.FileName, productDto.MainImageFile.ContentType); + + // WICHTIG: Wir fügen das Bild der Collection hinzu. existingProduct.Images.Add(new ProductImage { Url = url, IsMainImage = true, DisplayOrder = 1 }); imagesChanged = true; } - // Zusatzbilder hinzufügen + // A3. Zusatzbilder if (productDto.AdditionalImageFiles != null && productDto.AdditionalImageFiles.Any()) { int displayOrder = (existingProduct.Images.Any() ? existingProduct.Images.Max(i => i.DisplayOrder) : 0) + 1; @@ -192,63 +189,49 @@ namespace Webshop.Application.Services.Admin imagesChanged = true; } - // Kategorien aktualisieren - if (productDto.CategorieIds != null) - { - // Bestehende IDs - var currentIds = existingProduct.Productcategories.Select(pc => pc.categorieId).ToList(); - // Neue IDs - var newIds = productDto.CategorieIds; - - // Nur ändern, wenn wirklich anders (vermeidet unnötige DB-Writes) - if (!new HashSet(currentIds).SetEquals(newIds)) - { - existingProduct.Productcategories.Clear(); - foreach (var categorieId in newIds) - { - existingProduct.Productcategories.Add(new Productcategorie { categorieId = categorieId }); - } - } - } - - // SCHRITT A SPEICHERN (Nur wenn Bilder geändert wurden) + // ----------------------------------------------------------------------- + // SCHRITT A: SPEICHERN (NUR BILDER!) + // ----------------------------------------------------------------------- if (imagesChanged) { - // Wir aktualisieren den Zeitstempel manuell, damit der User Feedback hat - existingProduct.LastModifiedDate = DateTimeOffset.UtcNow; - existingProduct.RowVersion = Guid.NewGuid().ToByteArray(); - try { + // WICHTIG: Wir ändern HIER KEINE Eigenschaften am 'existingProduct' (wie RowVersion oder LastModified). + // Dadurch generiert EF Core nur SQL für die Tabelle "ProductImages", aber NICHT für "Products". + // Das verhindert den Concurrency-Fehler auf der Product-Tabelle. + Console.WriteLine("---- SPEICHERE BILDER ----"); await _context.SaveChangesAsync(); - // Da wir gespeichert haben, hat das Entity jetzt eine NEUE RowVersion in der DB. - // Aber unser EF-Kontext weiß das bereits, weil das Objekt getracked ist. + + // Da sich durch Trigger oder DB-Logik die Version des Produkts geändert haben KÖNNTE, + // laden wir das Produkt jetzt neu, um für Schritt B bereit zu sein. + await _context.Entry(existingProduct).ReloadAsync(); } catch (Exception ex) { - Console.WriteLine($"Fehler beim Bild-Speichern: {ex.Message}"); + Console.WriteLine($"Fehler Bild-Save: {ex.Message}"); return ServiceResult.Fail(ServiceResultType.Failure, "Fehler beim Speichern der Bilder."); } } // ----------------------------------------------------------------------- - // SCHRITT B: PRODUKT EIGENSCHAFTEN + // SCHRITT B: PRODUKT EIGENSCHAFTEN UPDATE // ----------------------------------------------------------------------- - // Check auf Duplikate (SKU/Slug) - nur wenn geändert - if (existingProduct.SKU != productDto.SKU) + // Kategorien + if (productDto.CategorieIds != null) { - bool skuExists = await _context.Products.AnyAsync(p => p.SKU == productDto.SKU && p.Id != productDto.Id); - if (skuExists) return ServiceResult.Fail(ServiceResultType.Conflict, $"SKU '{productDto.SKU}' existiert bereits."); + var currentIds = existingProduct.Productcategories.Select(pc => pc.categorieId).ToList(); + if (!new HashSet(currentIds).SetEquals(productDto.CategorieIds)) + { + existingProduct.Productcategories.Clear(); + foreach (var cId in productDto.CategorieIds) + { + existingProduct.Productcategories.Add(new Productcategorie { categorieId = cId }); + } + } } - if (existingProduct.Slug != productDto.Slug) - { - bool slugExists = await _context.Products.AnyAsync(p => p.Slug == productDto.Slug && p.Id != productDto.Id); - if (slugExists) return ServiceResult.Fail(ServiceResultType.Conflict, $"Slug '{productDto.Slug}' existiert bereits."); - } - - // Werte übernehmen + // Mapping der einfachen Felder existingProduct.Name = productDto.Name; existingProduct.Description = productDto.Description; existingProduct.SKU = productDto.SKU; @@ -263,20 +246,20 @@ namespace Webshop.Application.Services.Admin existingProduct.IsFeatured = productDto.IsFeatured; existingProduct.FeaturedDisplayOrder = productDto.FeaturedDisplayOrder; - // Immer aktualisieren + // JETZT aktualisieren wir die Metadaten des Produkts existingProduct.LastModifiedDate = DateTimeOffset.UtcNow; existingProduct.RowVersion = Guid.NewGuid().ToByteArray(); try { - Console.WriteLine("---- RUFE SaveChangesAsync (Properties) AUF ----"); + Console.WriteLine("---- SPEICHERE PRODUKT-DATEN ----"); await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { - // Sollte dank des manuellen Checks oben kaum noch passieren, - // es sei denn, millisekundengenaue Race-Condition. - return ServiceResult.Fail(ServiceResultType.Conflict, "Konflikt beim Speichern der Daten."); + // Da wir oben ReloadAsync gemacht haben, sollte das hier nur passieren, + // wenn in der Millisekunde zwischen Bild-Upload und Text-Update jemand anders gespeichert hat. + return ServiceResult.Fail(ServiceResultType.Conflict, "Konflikt beim Speichern der Produktdaten."); } return ServiceResult.Ok();