From d48d83c87d543e174bc02c4607416fce891fb8b4 Mon Sep 17 00:00:00 2001 From: Webtree-design Date: Fri, 28 Nov 2025 13:12:24 +0100 Subject: [PATCH] direkt aus dem warenkorb holen --- .../Customers/CheckoutController.cs | 27 ++- .../DTOs/Orders/CreateOrderDto.cs | 20 ++- .../Services/Customers/CheckoutService.cs | 166 ++++++++++++------ 3 files changed, 144 insertions(+), 69 deletions(-) diff --git a/Webshop.Api/Controllers/Customers/CheckoutController.cs b/Webshop.Api/Controllers/Customers/CheckoutController.cs index be65ca2..e96af5b 100644 --- a/Webshop.Api/Controllers/Customers/CheckoutController.cs +++ b/Webshop.Api/Controllers/Customers/CheckoutController.cs @@ -9,6 +9,8 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Webshop.Application; using System.Collections.Generic; +using Webshop.Application.Services.Customers; +using Microsoft.AspNetCore.Cors.Infrastructure; namespace Webshop.Api.Controllers.Customer { @@ -18,25 +20,34 @@ namespace Webshop.Api.Controllers.Customer public class CheckoutController : ControllerBase { private readonly ICheckoutService _checkoutService; + private readonly ICartService _cartService; - public CheckoutController(ICheckoutService checkoutService) + public CheckoutController(ICheckoutService checkoutService , ICartService cartService) { _checkoutService = checkoutService; + _cartService = cartService; } - // --- NEU: Endpoint um verfügbare Versandmethoden basierend auf dem Warenkorb zu holen --- - [HttpPost("available-shipping-methods")] + [HttpGet("available-shipping-methods")] // War vorher POST [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task GetAvailableShippingMethods([FromBody] ShippingCalculationRequestDto request) + public async Task GetAvailableShippingMethods() { - var result = await _checkoutService.GetCompatibleShippingMethodsAsync(request.Items); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + // 1. Warenkorb laden + var cartResult = await _cartService.GetCartAsync(userId!); + if (cartResult.Value == null || !cartResult.Value.Items.Any()) + { + return Ok(new List()); // Leerer Korb -> keine Methoden + } + + // 2. Berechnung aufrufen (nutzt die Overload Methode mit List) + var result = await _checkoutService.GetCompatibleShippingMethodsAsync(cartResult.Value.Items); return result.Type switch { ServiceResultType.Success => Ok(result.Value), - ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), - _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler beim Laden der Versandmethoden." }) + _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler." }) }; } diff --git a/Webshop.Application/DTOs/Orders/CreateOrderDto.cs b/Webshop.Application/DTOs/Orders/CreateOrderDto.cs index 2359168..2f19a69 100644 --- a/Webshop.Application/DTOs/Orders/CreateOrderDto.cs +++ b/Webshop.Application/DTOs/Orders/CreateOrderDto.cs @@ -1,21 +1,27 @@ -// src/Webshop.Application/DTOs/Orders/CreateOrderDto.cs using System; -using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace Webshop.Application.DTOs.Orders { public class CreateOrderDto { - public Guid? CustomerId { get; set; } // Nullable für Gastbestellung - public string? GuestEmail { get; set; } - public string? GuestPhoneNumber { get; set; } + // Items Liste wurde ENTFERNT! + + [Required] public Guid ShippingAddressId { get; set; } + + [Required] public Guid BillingAddressId { get; set; } + + [Required] public Guid PaymentMethodId { get; set; } + + [Required] public Guid ShippingMethodId { get; set; } - public string? CouponCode { get; set; } // << NEU >> + public string? CouponCode { get; set; } - public List Items { get; set; } = new List(); + // Optional: Falls du Gastbestellungen ohne vorherigen DB-Cart erlauben willst, + // bräuchtest du eine separate Logik. Wir gehen hier vom Standard User-Flow aus. } } \ No newline at end of file diff --git a/Webshop.Application/Services/Customers/CheckoutService.cs b/Webshop.Application/Services/Customers/CheckoutService.cs index 53ec71e..8ef753a 100644 --- a/Webshop.Application/Services/Customers/CheckoutService.cs +++ b/Webshop.Application/Services/Customers/CheckoutService.cs @@ -117,46 +117,54 @@ namespace Webshop.Application.Services.Customers // 1.1 Kunde Validieren var customer = await _customerRepository.GetByUserIdAsync(userId!); if (customer == null) - return ServiceResult.Fail(ServiceResultType.Unauthorized, "Kundenprofil nicht gefunden."); + return ServiceResult.Fail(ServiceResultType.Unauthorized, "Kundenprofil fehlt."); - // ================================================================================= - // FIX: E-Mail holen (Passend zu deiner Entity Customer.cs) - // Customer hat keine Email, aber eine AspNetUserId. Wir holen die Mail aus der Users Tabelle. - // ================================================================================= + // E-Mail Logik var userEmail = await _context.Users - .Where(u => u.Id == userId) // userId entspricht customer.AspNetUserId + .Where(u => u.Id == userId) .Select(u => u.Email) .FirstOrDefaultAsync(); if (string.IsNullOrEmpty(userEmail)) + return ServiceResult.Fail(ServiceResultType.Failure, "Keine E-Mail gefunden."); + + // ================================================================================= + // 1.2 Warenkorb aus der Datenbank laden (Single Source of Truth) + // ================================================================================= + var cart = await _context.Carts + .Include(c => c.Items) + .FirstOrDefaultAsync(c => c.UserId == userId); + + if (cart == null || !cart.Items.Any()) { - return ServiceResult.Fail(ServiceResultType.Failure, "Keine E-Mail-Adresse für den Benutzer gefunden."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Ihr Warenkorb ist leer. Bitte fügen Sie Produkte hinzu."); } - // 1.2 Adressen Validieren + // 1.3 Adressen Validieren + // Wir laden beide Adressen und stellen sicher, dass sie dem Kunden gehören var addresses = await _context.Addresses - .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) - .ToListAsync(); + .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) + .ToListAsync(); if (!addresses.Any(a => a.Id == orderDto.ShippingAddressId) || !addresses.Any(a => a.Id == orderDto.BillingAddressId)) { return ServiceResult.Fail(ServiceResultType.Forbidden, "Die angegebenen Adressen sind ungültig oder gehören nicht zu diesem Konto."); } - // 1.3 Methoden Validieren + // 1.4 Methoden Validieren var shippingMethod = await _shippingMethodRepository.GetByIdAsync(orderDto.ShippingMethodId); if (shippingMethod == null || !shippingMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Versandmethode ungültig."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Versandart ungültig."); var paymentMethod = await _context.PaymentMethods.FindAsync(orderDto.PaymentMethodId); if (paymentMethod == null || !paymentMethod.IsActive) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Zahlungsmethode ungültig."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, "Zahlungsart ungültig."); - // 1.4 Produkte Validieren - if (orderDto.Items == null || !orderDto.Items.Any()) - return ServiceResult.Fail(ServiceResultType.InvalidInput, "Warenkorb ist leer."); + // ================================================================================= + // 1.5 Produkte Validieren & Vorbereiten (BASIEREND AUF CART ITEMS) + // ================================================================================= - var productIds = orderDto.Items.Select(i => i.ProductId).Distinct().ToList(); + var productIds = cart.Items.Select(i => i.ProductId).Distinct().ToList(); var products = await _context.Products.Where(p => productIds.Contains(p.Id)).ToListAsync(); var validationErrors = new StringBuilder(); @@ -164,48 +172,69 @@ namespace Webshop.Application.Services.Customers decimal itemsTotal = 0; var preparedOrderItems = new List(); - foreach (var itemDto in orderDto.Items) + foreach (var cartItem in cart.Items) { - var product = products.FirstOrDefault(p => p.Id == itemDto.ProductId); + var product = products.FirstOrDefault(p => p.Id == cartItem.ProductId); - if (product == null) { validationErrors.AppendLine($"- Produkt {itemDto.ProductId} fehlt."); continue; } - if (!product.IsActive) validationErrors.AppendLine($"- '{product.Name}' ist nicht verfügbar."); - if (product.StockQuantity < itemDto.Quantity) validationErrors.AppendLine($"- '{product.Name}': Zu wenig Bestand."); + if (product == null) + { + validationErrors.AppendLine($"- Ein Produkt im Warenkorb (ID {cartItem.ProductId}) existiert nicht mehr."); + continue; + } + + if (!product.IsActive) + { + validationErrors.AppendLine($"- '{product.Name}' ist derzeit nicht verfügbar."); + } + + // Prüfung Menge + if (product.StockQuantity < cartItem.Quantity) + { + validationErrors.AppendLine($"- '{product.Name}': Nicht genügend Bestand (Verfügbar: {product.StockQuantity}, im Korb: {cartItem.Quantity})."); + } if (validationErrors.Length == 0) { - totalOrderWeight += (decimal)(product.Weight ?? 0) * itemDto.Quantity; + // Gewicht berechnen (Fallback auf 0 falls null) + totalOrderWeight += (decimal)(product.Weight ?? 0) * cartItem.Quantity; + var orderItem = new OrderItem { ProductId = product.Id, - ProductVariantId = itemDto.ProductVariantId, + ProductVariantId = cartItem.ProductVariantId, ProductName = product.Name, ProductSKU = product.SKU, - Quantity = itemDto.Quantity, - UnitPrice = product.Price, - TotalPrice = product.Price * itemDto.Quantity + Quantity = cartItem.Quantity, + UnitPrice = product.Price, // Preis IMMER frisch aus der DB nehmen! + TotalPrice = product.Price * cartItem.Quantity }; preparedOrderItems.Add(orderItem); itemsTotal += orderItem.TotalPrice; } } + // Abbruch bei Produktfehlern if (validationErrors.Length > 0) { await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, $"Fehler:\n{validationErrors}"); + return ServiceResult.Fail(ServiceResultType.Conflict, $"Bestellung nicht möglich:\n{validationErrors}"); } + // 1.6 Gewichtsprüfung if (totalOrderWeight < shippingMethod.MinWeight || totalOrderWeight > shippingMethod.MaxWeight) { await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart."); + return ServiceResult.Fail(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart '{shippingMethod.Name}'."); } - // 2. Preise & Rabatte + // ================================================================================= + // PHASE 2: BERECHNUNG (Finanzen) + // ================================================================================= + decimal discountAmount = 0; var discountResult = new DiscountCalculationResult(); + // Rabatt berechnen if (!string.IsNullOrWhiteSpace(orderDto.CouponCode)) { discountResult = await _discountService.CalculateDiscountAsync(preparedOrderItems, orderDto.CouponCode); @@ -218,27 +247,42 @@ namespace Webshop.Application.Services.Customers discountAmount = discountResult.TotalDiscountAmount; } + // Gesamtsummen berechnen decimal shippingCost = shippingMethod.BaseCost; decimal subTotalBeforeDiscount = itemsTotal + shippingCost; - if (discountAmount > subTotalBeforeDiscount) discountAmount = subTotalBeforeDiscount; - decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; - decimal taxRate = await _settingService.GetSettingValueAsync("GlobalTaxRate", 0.19m); - decimal taxAmount = subTotalAfterDiscount * taxRate; // Steuer auf reduzierten Betrag - decimal orderTotal = subTotalAfterDiscount + taxAmount; - - // 3. Ausführung (Execution) - foreach (var itemDto in orderDto.Items) + // Rabatt darf nicht höher als die Summe sein + if (discountAmount > subTotalBeforeDiscount) { - var product = products.First(p => p.Id == itemDto.ProductId); - if (product.StockQuantity < itemDto.Quantity) - { - await transaction.RollbackAsync(); - return ServiceResult.Fail(ServiceResultType.Conflict, "Lagerbestand hat sich während des Vorgangs geändert."); - } - product.StockQuantity -= itemDto.Quantity; + discountAmount = subTotalBeforeDiscount; } + decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; + + // Steuer berechnen (Settings abrufen) + decimal taxRate = await _settingService.GetSettingValueAsync("GlobalTaxRate", 0.19m); + decimal taxAmount = subTotalAfterDiscount * taxRate; + + decimal orderTotal = subTotalAfterDiscount + taxAmount; + + // ================================================================================= + // PHASE 3: EXECUTION (Schreiben in DB) + // ================================================================================= + + // 3.1 Lagerbestand reduzieren + foreach (var oi in preparedOrderItems) + { + var product = products.First(p => p.Id == oi.ProductId); + // Letzte Sicherheitsprüfung (Concurrency) + if (product.StockQuantity < oi.Quantity) + { + await transaction.RollbackAsync(); + return ServiceResult.Fail(ServiceResultType.Conflict, $"Lagerbestand für '{product.Name}' hat sich während des Vorgangs geändert."); + } + product.StockQuantity -= oi.Quantity; + } + + // 3.2 Order erstellen var newOrder = new Order { CustomerId = customer.Id, @@ -260,6 +304,7 @@ namespace Webshop.Application.Services.Customers await _orderRepository.AddAsync(newOrder); + // 3.3 Discount Nutzung erhöhen if (discountResult.AppliedDiscountIds.Any()) { var appliedDiscounts = await _context.Discounts @@ -268,6 +313,7 @@ namespace Webshop.Application.Services.Customers foreach (var d in appliedDiscounts) d.CurrentUsageCount++; } + // 3.4 Speichern & Commit try { await _context.SaveChangesAsync(); @@ -276,18 +322,30 @@ namespace Webshop.Application.Services.Customers catch (DbUpdateConcurrencyException ex) { await transaction.RollbackAsync(); - _logger.LogWarning(ex, "Concurrency Konflikt bei Bestellung."); - return ServiceResult.Fail(ServiceResultType.Conflict, "Artikel wurden soeben von einem anderen Kunden gekauft."); + _logger.LogWarning(ex, "Concurrency Fehler beim Checkout (User {UserId}).", userId); + return ServiceResult.Fail(ServiceResultType.Conflict, "Ein oder mehrere Artikel wurden soeben von einem anderen Kunden gekauft."); } - // 4. Post-Processing (Warenkorb & Email) - try { await _cartService.ClearCartAsync(userId!); } - catch (Exception ex) { _logger.LogError(ex, "Warenkorb konnte nicht geleert werden."); } + // ================================================================================= + // PHASE 4: CLEANUP & POST-PROCESSING + // ================================================================================= - try { await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); } - catch (Exception ex) { _logger.LogError(ex, "Bestätigungs-Email fehlgeschlagen."); } + // 4.1 Warenkorb leeren + try + { + await _cartService.ClearCartAsync(userId!); + } + catch (Exception ex) { _logger.LogError(ex, "Cart Clear Failed for Order {OrderNumber}", newOrder.OrderNumber); } - // Manuelles Setzen der Navigation Properties für das Mapping + // 4.2 Bestätigungs-Email senden + try + { + await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); + } + catch (Exception ex) { _logger.LogError(ex, "Email Failed for Order {OrderNumber}", newOrder.OrderNumber); } + + // 4.3 Mapping für Rückgabe + // Wir setzen die Navigation Properties manuell, um DB-Reload zu sparen newOrder.PaymentMethodInfo = paymentMethod; newOrder.ShippingAddress = addresses.First(a => a.Id == orderDto.ShippingAddressId); newOrder.BillingAddress = addresses.First(a => a.Id == orderDto.BillingAddressId); @@ -297,7 +355,7 @@ namespace Webshop.Application.Services.Customers catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError(ex, "Checkout Exception"); + _logger.LogError(ex, "Unerwarteter Fehler im Checkout für User {UserId}", userId); return ServiceResult.Fail(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten."); } }