direkt aus dem warenkorb holen
All checks were successful
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Successful in 29s

This commit is contained in:
Webtree-design
2025-11-28 13:12:24 +01:00
parent 19366febb8
commit d48d83c87d
3 changed files with 144 additions and 69 deletions

View File

@@ -9,6 +9,8 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Webshop.Application; using Webshop.Application;
using System.Collections.Generic; using System.Collections.Generic;
using Webshop.Application.Services.Customers;
using Microsoft.AspNetCore.Cors.Infrastructure;
namespace Webshop.Api.Controllers.Customer namespace Webshop.Api.Controllers.Customer
{ {
@@ -18,25 +20,34 @@ namespace Webshop.Api.Controllers.Customer
public class CheckoutController : ControllerBase public class CheckoutController : ControllerBase
{ {
private readonly ICheckoutService _checkoutService; private readonly ICheckoutService _checkoutService;
private readonly ICartService _cartService;
public CheckoutController(ICheckoutService checkoutService) public CheckoutController(ICheckoutService checkoutService , ICartService cartService)
{ {
_checkoutService = checkoutService; _checkoutService = checkoutService;
_cartService = cartService;
} }
// --- NEU: Endpoint um verf<72>gbare Versandmethoden basierend auf dem Warenkorb zu holen --- [HttpGet("available-shipping-methods")] // War vorher POST
[HttpPost("available-shipping-methods")]
[ProducesResponseType(typeof(IEnumerable<ShippingMethodDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<ShippingMethodDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] public async Task<IActionResult> GetAvailableShippingMethods()
public async Task<IActionResult> GetAvailableShippingMethods([FromBody] ShippingCalculationRequestDto request)
{ {
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<ShippingMethodDto>()); // Leerer Korb -> keine Methoden
}
// 2. Berechnung aufrufen (nutzt die Overload Methode mit List<CartItemDto>)
var result = await _checkoutService.GetCompatibleShippingMethodsAsync(cartResult.Value.Items);
return result.Type switch return result.Type switch
{ {
ServiceResultType.Success => Ok(result.Value), ServiceResultType.Success => Ok(result.Value),
ServiceResultType.InvalidInput => BadRequest(new { Message = result.ErrorMessage }), _ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler." })
_ => StatusCode(StatusCodes.Status500InternalServerError, new { Message = "Fehler beim Laden der Versandmethoden." })
}; };
} }

View File

@@ -1,21 +1,27 @@
// src/Webshop.Application/DTOs/Orders/CreateOrderDto.cs
using System; using System;
using System.Collections.Generic; using System.ComponentModel.DataAnnotations;
namespace Webshop.Application.DTOs.Orders namespace Webshop.Application.DTOs.Orders
{ {
public class CreateOrderDto public class CreateOrderDto
{ {
public Guid? CustomerId { get; set; } // Nullable für Gastbestellung // Items Liste wurde ENTFERNT!
public string? GuestEmail { get; set; }
public string? GuestPhoneNumber { get; set; } [Required]
public Guid ShippingAddressId { get; set; } public Guid ShippingAddressId { get; set; }
[Required]
public Guid BillingAddressId { get; set; } public Guid BillingAddressId { get; set; }
[Required]
public Guid PaymentMethodId { get; set; } public Guid PaymentMethodId { get; set; }
[Required]
public Guid ShippingMethodId { get; set; } public Guid ShippingMethodId { get; set; }
public string? CouponCode { get; set; } // << NEU >> public string? CouponCode { get; set; }
public List<CreateOrderItemDto> Items { get; set; } = new List<CreateOrderItemDto>(); // Optional: Falls du Gastbestellungen ohne vorherigen DB-Cart erlauben willst,
// bräuchtest du eine separate Logik. Wir gehen hier vom Standard User-Flow aus.
} }
} }

View File

@@ -117,23 +117,31 @@ namespace Webshop.Application.Services.Customers
// 1.1 Kunde Validieren // 1.1 Kunde Validieren
var customer = await _customerRepository.GetByUserIdAsync(userId!); var customer = await _customerRepository.GetByUserIdAsync(userId!);
if (customer == null) if (customer == null)
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Unauthorized, "Kundenprofil nicht gefunden."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Unauthorized, "Kundenprofil fehlt.");
// ================================================================================= // E-Mail Logik
// 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.
// =================================================================================
var userEmail = await _context.Users var userEmail = await _context.Users
.Where(u => u.Id == userId) // userId entspricht customer.AspNetUserId .Where(u => u.Id == userId)
.Select(u => u.Email) .Select(u => u.Email)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (string.IsNullOrEmpty(userEmail)) if (string.IsNullOrEmpty(userEmail))
return ServiceResult.Fail<OrderDetailDto>(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<OrderDetailDto>(ServiceResultType.Failure, "Keine E-Mail-Adresse für den Benutzer gefunden."); return ServiceResult.Fail<OrderDetailDto>(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 var addresses = await _context.Addresses
.Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id) .Where(a => (a.Id == orderDto.ShippingAddressId || a.Id == orderDto.BillingAddressId) && a.CustomerId == customer.Id)
.ToListAsync(); .ToListAsync();
@@ -143,20 +151,20 @@ namespace Webshop.Application.Services.Customers
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Forbidden, "Die angegebenen Adressen sind ungültig oder gehören nicht zu diesem Konto."); return ServiceResult.Fail<OrderDetailDto>(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); var shippingMethod = await _shippingMethodRepository.GetByIdAsync(orderDto.ShippingMethodId);
if (shippingMethod == null || !shippingMethod.IsActive) if (shippingMethod == null || !shippingMethod.IsActive)
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, "Versandmethode ungültig."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, "Versandart ungültig.");
var paymentMethod = await _context.PaymentMethods.FindAsync(orderDto.PaymentMethodId); var paymentMethod = await _context.PaymentMethods.FindAsync(orderDto.PaymentMethodId);
if (paymentMethod == null || !paymentMethod.IsActive) if (paymentMethod == null || !paymentMethod.IsActive)
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, "Zahlungsmethode ungültig."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, "Zahlungsart ungültig.");
// 1.4 Produkte Validieren // =================================================================================
if (orderDto.Items == null || !orderDto.Items.Any()) // 1.5 Produkte Validieren & Vorbereiten (BASIEREND AUF CART ITEMS)
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, "Warenkorb ist leer."); // =================================================================================
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 products = await _context.Products.Where(p => productIds.Contains(p.Id)).ToListAsync();
var validationErrors = new StringBuilder(); var validationErrors = new StringBuilder();
@@ -164,48 +172,69 @@ namespace Webshop.Application.Services.Customers
decimal itemsTotal = 0; decimal itemsTotal = 0;
var preparedOrderItems = new List<OrderItem>(); var preparedOrderItems = new List<OrderItem>();
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 == null)
if (!product.IsActive) validationErrors.AppendLine($"- '{product.Name}' ist nicht verfügbar."); {
if (product.StockQuantity < itemDto.Quantity) validationErrors.AppendLine($"- '{product.Name}': Zu wenig Bestand."); 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) 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 var orderItem = new OrderItem
{ {
ProductId = product.Id, ProductId = product.Id,
ProductVariantId = itemDto.ProductVariantId, ProductVariantId = cartItem.ProductVariantId,
ProductName = product.Name, ProductName = product.Name,
ProductSKU = product.SKU, ProductSKU = product.SKU,
Quantity = itemDto.Quantity, Quantity = cartItem.Quantity,
UnitPrice = product.Price, UnitPrice = product.Price, // Preis IMMER frisch aus der DB nehmen!
TotalPrice = product.Price * itemDto.Quantity TotalPrice = product.Price * cartItem.Quantity
}; };
preparedOrderItems.Add(orderItem); preparedOrderItems.Add(orderItem);
itemsTotal += orderItem.TotalPrice; itemsTotal += orderItem.TotalPrice;
} }
} }
// Abbruch bei Produktfehlern
if (validationErrors.Length > 0) if (validationErrors.Length > 0)
{ {
await transaction.RollbackAsync(); await transaction.RollbackAsync();
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, $"Fehler:\n{validationErrors}"); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, $"Bestellung nicht möglich:\n{validationErrors}");
} }
// 1.6 Gewichtsprüfung
if (totalOrderWeight < shippingMethod.MinWeight || totalOrderWeight > shippingMethod.MaxWeight) if (totalOrderWeight < shippingMethod.MinWeight || totalOrderWeight > shippingMethod.MaxWeight)
{ {
await transaction.RollbackAsync(); await transaction.RollbackAsync();
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.InvalidInput, $"Gesamtgewicht ({totalOrderWeight} kg) passt nicht zur Versandart '{shippingMethod.Name}'.");
} }
// 2. Preise & Rabatte // =================================================================================
// PHASE 2: BERECHNUNG (Finanzen)
// =================================================================================
decimal discountAmount = 0; decimal discountAmount = 0;
var discountResult = new DiscountCalculationResult(); var discountResult = new DiscountCalculationResult();
// Rabatt berechnen
if (!string.IsNullOrWhiteSpace(orderDto.CouponCode)) if (!string.IsNullOrWhiteSpace(orderDto.CouponCode))
{ {
discountResult = await _discountService.CalculateDiscountAsync(preparedOrderItems, orderDto.CouponCode); discountResult = await _discountService.CalculateDiscountAsync(preparedOrderItems, orderDto.CouponCode);
@@ -218,27 +247,42 @@ namespace Webshop.Application.Services.Customers
discountAmount = discountResult.TotalDiscountAmount; discountAmount = discountResult.TotalDiscountAmount;
} }
// Gesamtsummen berechnen
decimal shippingCost = shippingMethod.BaseCost; decimal shippingCost = shippingMethod.BaseCost;
decimal subTotalBeforeDiscount = itemsTotal + shippingCost; decimal subTotalBeforeDiscount = itemsTotal + shippingCost;
if (discountAmount > subTotalBeforeDiscount) discountAmount = subTotalBeforeDiscount;
// Rabatt darf nicht höher als die Summe sein
if (discountAmount > subTotalBeforeDiscount)
{
discountAmount = subTotalBeforeDiscount;
}
decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount;
// Steuer berechnen (Settings abrufen)
decimal taxRate = await _settingService.GetSettingValueAsync<decimal>("GlobalTaxRate", 0.19m); decimal taxRate = await _settingService.GetSettingValueAsync<decimal>("GlobalTaxRate", 0.19m);
decimal taxAmount = subTotalAfterDiscount * taxRate; // Steuer auf reduzierten Betrag decimal taxAmount = subTotalAfterDiscount * taxRate;
decimal orderTotal = subTotalAfterDiscount + taxAmount; decimal orderTotal = subTotalAfterDiscount + taxAmount;
// 3. Ausführung (Execution) // =================================================================================
foreach (var itemDto in orderDto.Items) // PHASE 3: EXECUTION (Schreiben in DB)
// =================================================================================
// 3.1 Lagerbestand reduzieren
foreach (var oi in preparedOrderItems)
{ {
var product = products.First(p => p.Id == itemDto.ProductId); var product = products.First(p => p.Id == oi.ProductId);
if (product.StockQuantity < itemDto.Quantity) // Letzte Sicherheitsprüfung (Concurrency)
if (product.StockQuantity < oi.Quantity)
{ {
await transaction.RollbackAsync(); await transaction.RollbackAsync();
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, "Lagerbestand hat sich während des Vorgangs geändert."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, $"Lagerbestand für '{product.Name}' hat sich während des Vorgangs geändert.");
} }
product.StockQuantity -= itemDto.Quantity; product.StockQuantity -= oi.Quantity;
} }
// 3.2 Order erstellen
var newOrder = new Order var newOrder = new Order
{ {
CustomerId = customer.Id, CustomerId = customer.Id,
@@ -260,6 +304,7 @@ namespace Webshop.Application.Services.Customers
await _orderRepository.AddAsync(newOrder); await _orderRepository.AddAsync(newOrder);
// 3.3 Discount Nutzung erhöhen
if (discountResult.AppliedDiscountIds.Any()) if (discountResult.AppliedDiscountIds.Any())
{ {
var appliedDiscounts = await _context.Discounts var appliedDiscounts = await _context.Discounts
@@ -268,6 +313,7 @@ namespace Webshop.Application.Services.Customers
foreach (var d in appliedDiscounts) d.CurrentUsageCount++; foreach (var d in appliedDiscounts) d.CurrentUsageCount++;
} }
// 3.4 Speichern & Commit
try try
{ {
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@@ -276,18 +322,30 @@ namespace Webshop.Application.Services.Customers
catch (DbUpdateConcurrencyException ex) catch (DbUpdateConcurrencyException ex)
{ {
await transaction.RollbackAsync(); await transaction.RollbackAsync();
_logger.LogWarning(ex, "Concurrency Konflikt bei Bestellung."); _logger.LogWarning(ex, "Concurrency Fehler beim Checkout (User {UserId}).", userId);
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, "Artikel wurden soeben von einem anderen Kunden gekauft."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, "Ein oder mehrere Artikel wurden soeben von einem anderen Kunden gekauft.");
} }
// 4. Post-Processing (Warenkorb & Email) // =================================================================================
try { await _cartService.ClearCartAsync(userId!); } // PHASE 4: CLEANUP & POST-PROCESSING
catch (Exception ex) { _logger.LogError(ex, "Warenkorb konnte nicht geleert werden."); } // =================================================================================
try { await _emailService.SendOrderConfirmationAsync(newOrder.Id, userEmail); } // 4.1 Warenkorb leeren
catch (Exception ex) { _logger.LogError(ex, "Bestätigungs-Email fehlgeschlagen."); } 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.PaymentMethodInfo = paymentMethod;
newOrder.ShippingAddress = addresses.First(a => a.Id == orderDto.ShippingAddressId); newOrder.ShippingAddress = addresses.First(a => a.Id == orderDto.ShippingAddressId);
newOrder.BillingAddress = addresses.First(a => a.Id == orderDto.BillingAddressId); newOrder.BillingAddress = addresses.First(a => a.Id == orderDto.BillingAddressId);
@@ -297,7 +355,7 @@ namespace Webshop.Application.Services.Customers
catch (Exception ex) catch (Exception ex)
{ {
await transaction.RollbackAsync(); await transaction.RollbackAsync();
_logger.LogError(ex, "Checkout Exception"); _logger.LogError(ex, "Unerwarteter Fehler im Checkout für User {UserId}", userId);
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten."); return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Failure, "Ein unerwarteter Fehler ist aufgetreten.");
} }
} }