Compare commits

..

9 Commits

Author SHA1 Message Date
4e23338553 Merge pull request 'test' (#2) from test into develop
Reviewed-on: #2
2025-12-04 11:54:17 +00:00
Webtree-design
d48d83c87d 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
2025-11-28 13:12:24 +01:00
Tizian.Breuch
19366febb8 migration
All checks were successful
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Successful in 25s
2025-11-26 17:44:16 +01:00
Tizian.Breuch
de67e01f2c fix
All checks were successful
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Successful in 25s
2025-11-26 17:31:28 +01:00
Tizian.Breuch
9c48f62ff0 cart
Some checks failed
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Failing after 20s
2025-11-26 17:21:01 +01:00
Tizian.Breuch
c4d4a27798 Merge branch 'test' of https://gitea.tzbre.dev/admin/ShopSolution-backend into test
All checks were successful
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Successful in 28s
2025-11-26 17:17:00 +01:00
Tizian.Breuch
3a2ea2c307 migration 2025-11-26 17:16:47 +01:00
Tizian.Breuch
5510ffa836 pfad 2025-11-26 17:09:41 +01:00
95c07339ab .gitea/workflows/pipeline.yml aktualisiert
All checks were successful
Branch - test - Build and Push Backend API Docker Image / build-and-push (push) Successful in 26s
2025-11-26 16:02:12 +00:00
18 changed files with 1984 additions and 96 deletions

View File

@@ -39,4 +39,4 @@ jobs:
# Legt fest, dass das Image nach dem Bauen in die Registry gepusht werden soll # Legt fest, dass das Image nach dem Bauen in die Registry gepusht werden soll
push: true push: true
# Definiert die Tags für Ihr Docker-Image in der Registry # Definiert die Tags für Ihr Docker-Image in der Registry
tags: gitea.tzbre.dev/admin/shopsolution-backend:test # WICHTIG: Eindeutiger Name für das Backend-Image tags: gitea.tzbre.dev/admin/shopsolution-backend:dev # WICHTIG: Eindeutiger Name für das Backend-Image

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Webshop.Application;
using Webshop.Application.DTOs.Customers; // Für CartDto
using Webshop.Application.DTOs.Shipping; // Für CartItemDto
using Webshop.Application.Services.Customers.Interfaces;
namespace Webshop.Api.Controllers.Customer
{
[ApiController]
[Route("api/v1/customer/[controller]")]
[Authorize(Roles = "Customer")]
public class CartController : ControllerBase // <--- WICHTIG: Muss public sein und erben
{
private readonly ICartService _cartService;
public CartController(ICartService cartService)
{
_cartService = cartService;
}
[HttpGet]
[ProducesResponseType(typeof(CartDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCart()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _cartService.GetCartAsync(userId!);
return Ok(result.Value);
}
[HttpPost("items")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AddToCart([FromBody] CartItemDto item)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _cartService.AddToCartAsync(userId!, item);
return result.Type == ServiceResultType.Success ? Ok() : BadRequest(new { Message = result.ErrorMessage });
}
[HttpDelete("items/{productId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoveItem(Guid productId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _cartService.RemoveFromCartAsync(userId!, productId);
return NoContent();
}
}
}

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

@@ -10,6 +10,7 @@ using Webshop.Application.DTOs.Auth;
using Webshop.Application.DTOs.Customers; using Webshop.Application.DTOs.Customers;
using Webshop.Application.DTOs.Email; using Webshop.Application.DTOs.Email;
using Webshop.Application.Services.Customers; using Webshop.Application.Services.Customers;
using Webshop.Application.Services.Customers.Interfaces;
namespace Webshop.Api.Controllers.Customer namespace Webshop.Api.Controllers.Customer
{ {

View File

@@ -27,6 +27,7 @@ using Webshop.Infrastructure.Data;
using Webshop.Infrastructure.Repositories; using Webshop.Infrastructure.Repositories;
using Webshop.Infrastructure.Services; using Webshop.Infrastructure.Services;
var options = new WebApplicationOptions var options = new WebApplicationOptions
{ {
Args = args, Args = args,

View File

@@ -16,7 +16,19 @@
"ExpirationMinutes": 120 "ExpirationMinutes": 120
}, },
"Resend": { "Resend": {
"ApiToken": "re_Zd6aFcvz_CqSa9Krs7WCHXjngDVLTYhGv" "ApiToken": "re_Zd6aFcvz_CqSa9Krs7WCHXjngDVLTYhGv",
"FromEmail": "onboarding@resend.dev"
}, },
"App:BaseUrl": "https://shopsolution-backend.tzbre.dev" "App": {
"BaseUrl": "https://shopsolution-backend.tzbre.dev",
"ClientUrl": "https://shopsolution-frontend.tzbre.dev",
"FrontendUrl": "https://shopsolution-frontend.tzbre.dev"
},
"ShopInfo": {
"Name": "Mein Webshop"
},
"EmailSettings": {
"SenderName": "Mein Webshop Support",
"SenderEmail": "noreply@shopsolution-frontend.tzbre.dev"
}
} }

View File

@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using Webshop.Application.DTOs.Shipping; // Wichtig für CartItemDto
namespace Webshop.Application.DTOs.Customers
{
public class CartDto
{
public Guid Id { get; set; }
public string UserId { get; set; } = string.Empty;
public List<CartItemDto> Items { get; set; } = new List<CartItemDto>();
// Optional: Gesamtsumme zur Anzeige im Frontend
public decimal TotalPrice { get; set; }
}
}

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

@@ -1,7 +1,12 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Webshop.Application;
using Webshop.Application.DTOs.Customers; // <--- DIESE ZEILE HAT GEFEHLT (für CartDto)
using Webshop.Application.DTOs.Shipping; // (für CartItemDto)
using Webshop.Application.Services.Customers.Interfaces; using Webshop.Application.Services.Customers.Interfaces;
using Webshop.Domain.Entities;
using Webshop.Infrastructure.Data; using Webshop.Infrastructure.Data;
namespace Webshop.Application.Services.Customers namespace Webshop.Application.Services.Customers
@@ -15,24 +20,95 @@ namespace Webshop.Application.Services.Customers
_context = context; _context = context;
} }
// Die Implementierung muss exakt so aussehen:
public async Task<ServiceResult<CartDto>> GetCartAsync(string userId)
{
var cart = await GetCartEntity(userId);
// Wenn kein Warenkorb da ist, geben wir einen leeren zurück (Success)
if (cart == null)
{
return ServiceResult.Ok(new CartDto { UserId = userId });
}
var dto = new CartDto
{
Id = cart.Id,
UserId = userId,
Items = cart.Items.Select(i => new CartItemDto
{
ProductId = i.ProductId,
ProductVariantId = i.ProductVariantId,
Quantity = i.Quantity
}).ToList()
};
return ServiceResult.Ok(dto);
}
public async Task<ServiceResult> AddToCartAsync(string userId, CartItemDto itemDto)
{
var cart = await GetCartEntity(userId);
if (cart == null)
{
cart = new Cart { UserId = userId, Id = Guid.NewGuid() };
_context.Carts.Add(cart);
}
var existingItem = cart.Items.FirstOrDefault(i => i.ProductId == itemDto.ProductId && i.ProductVariantId == itemDto.ProductVariantId);
if (existingItem != null)
{
existingItem.Quantity += itemDto.Quantity;
}
else
{
var newItem = new CartItem
{
CartId = cart.Id,
ProductId = itemDto.ProductId,
ProductVariantId = itemDto.ProductVariantId,
Quantity = itemDto.Quantity
};
// WICHTIG: Zur Collection hinzufügen UND dem Context Bescheid geben
cart.Items.Add(newItem);
_context.CartItems.Add(newItem);
}
cart.LastModified = DateTime.UtcNow;
await _context.SaveChangesAsync();
return ServiceResult.Ok();
}
public async Task<ServiceResult> RemoveFromCartAsync(string userId, Guid productId)
{
var cart = await GetCartEntity(userId);
if (cart == null) return ServiceResult.Fail(ServiceResultType.NotFound, "Warenkorb leer.");
var item = cart.Items.FirstOrDefault(i => i.ProductId == productId);
if (item != null)
{
_context.CartItems.Remove(item);
await _context.SaveChangesAsync();
}
return ServiceResult.Ok();
}
public async Task ClearCartAsync(string userId) public async Task ClearCartAsync(string userId)
{ {
// Wir suchen den Warenkorb des Users var cart = await GetCartEntity(userId);
// Annahme: Es gibt eine 'Carts' Tabelle und 'CartItems'
var cart = await _context.Carts
.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.UserId == userId);
if (cart != null && cart.Items.Any()) if (cart != null && cart.Items.Any())
{ {
// Option A: Nur Items löschen, Warenkorb behalten
_context.CartItems.RemoveRange(cart.Items); _context.CartItems.RemoveRange(cart.Items);
// Option B: Ganzen Warenkorb löschen (dann wird er beim nächsten AddToCart neu erstellt)
// _context.Carts.Remove(cart);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
} }
private async Task<Cart?> GetCartEntity(string userId)
{
return await _context.Carts
.Include(c => c.Items)
.FirstOrDefaultAsync(c => c.UserId == userId);
}
} }
} }

View File

@@ -117,46 +117,54 @@ 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();
if (!addresses.Any(a => a.Id == orderDto.ShippingAddressId) || !addresses.Any(a => a.Id == orderDto.BillingAddressId)) if (!addresses.Any(a => a.Id == orderDto.ShippingAddressId) || !addresses.Any(a => a.Id == orderDto.BillingAddressId))
{ {
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;
decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount; // Rabatt darf nicht höher als die Summe sein
decimal taxRate = await _settingService.GetSettingValueAsync<decimal>("GlobalTaxRate", 0.19m); if (discountAmount > subTotalBeforeDiscount)
decimal taxAmount = subTotalAfterDiscount * taxRate; // Steuer auf reduzierten Betrag
decimal orderTotal = subTotalAfterDiscount + taxAmount;
// 3. Ausführung (Execution)
foreach (var itemDto in orderDto.Items)
{ {
var product = products.First(p => p.Id == itemDto.ProductId); discountAmount = subTotalBeforeDiscount;
if (product.StockQuantity < itemDto.Quantity)
{
await transaction.RollbackAsync();
return ServiceResult.Fail<OrderDetailDto>(ServiceResultType.Conflict, "Lagerbestand hat sich während des Vorgangs geändert.");
}
product.StockQuantity -= itemDto.Quantity;
} }
decimal subTotalAfterDiscount = subTotalBeforeDiscount - discountAmount;
// Steuer berechnen (Settings abrufen)
decimal taxRate = await _settingService.GetSettingValueAsync<decimal>("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<OrderDetailDto>(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 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.");
} }
} }

View File

@@ -1,9 +1,16 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using System; // Für Guid
using Webshop.Application.DTOs.Customers; // <--- WICHTIG für CartDto
using Webshop.Application.DTOs.Shipping;
using Webshop.Application;
namespace Webshop.Application.Services.Customers.Interfaces namespace Webshop.Application.Services.Customers.Interfaces
{ {
public interface ICartService public interface ICartService
{ {
Task<ServiceResult<CartDto>> GetCartAsync(string userId);
Task<ServiceResult> AddToCartAsync(string userId, CartItemDto item);
Task<ServiceResult> RemoveFromCartAsync(string userId, Guid productId);
Task ClearCartAsync(string userId); Task ClearCartAsync(string userId);
} }
} }

View File

@@ -103,7 +103,7 @@ namespace Webshop.Application.Services.Public
private async Task<string> LoadAndFormatTemplate(string title, string body, string btnText, string btnLink) private async Task<string> LoadAndFormatTemplate(string title, string body, string btnText, string btnLink)
{ {
// Pfad: wwwroot/templates/email-template.html // Pfad: wwwroot/templates/email-template.html
var templatePath = Path.Combine(_env.WebRootPath ?? _env.ContentRootPath, "templates", "email-template.html"); var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "_EmailTemplate.html");
string template; string template;

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Webshop.Domain.Entities
{
public class Cart
{
[Key]
public Guid Id { get; set; }
// Verknüpfung zum User (String, da Identity User Id ein String ist)
public string? UserId { get; set; }
// Falls du auch Gast-Warenkörbe ohne Login unterstützt:
public string? SessionId { get; set; }
public DateTime LastModified { get; set; } = DateTime.UtcNow;
public virtual ICollection<CartItem> Items { get; set; } = new List<CartItem>();
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Webshop.Domain.Entities
{
public class CartItem
{
[Key]
public Guid Id { get; set; }
public Guid CartId { get; set; }
[ForeignKey("CartId")]
public virtual Cart Cart { get; set; } = default!;
public Guid ProductId { get; set; }
[ForeignKey("ProductId")]
public virtual Product Product { get; set; } = default!;
public Guid? ProductVariantId { get; set; } // Optional, falls du Varianten nutzt
public int Quantity { get; set; }
}
}

View File

@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Webshop.Domain.Entities; using Webshop.Domain.Entities;
using Webshop.Domain.Identity; using Webshop.Domain.Identity;
@@ -26,12 +25,16 @@ namespace Webshop.Infrastructure.Data
public DbSet<PaymentMethod> PaymentMethods { get; set; } = default!; public DbSet<PaymentMethod> PaymentMethods { get; set; } = default!;
public DbSet<Setting> Settings { get; set; } = default!; public DbSet<Setting> Settings { get; set; } = default!;
// Verknüpfungstabellen und Details
public DbSet<Productcategorie> Productcategories { get; set; } = default!; public DbSet<Productcategorie> Productcategories { get; set; } = default!;
public DbSet<ProductDiscount> ProductDiscounts { get; set; } = default!; public DbSet<ProductDiscount> ProductDiscounts { get; set; } = default!;
public DbSet<CategorieDiscount> categorieDiscounts { get; set; } = default!; public DbSet<CategorieDiscount> categorieDiscounts { get; set; } = default!;
public DbSet<ProductImage> ProductImages { get; set; } = default!; public DbSet<ProductImage> ProductImages { get; set; } = default!;
public DbSet<ShopInfo> ShopInfos { get; set; } = default!; public DbSet<ShopInfo> ShopInfos { get; set; } = default!;
// +++ NEU: Warenkorb Tabellen +++
public DbSet<Cart> Carts { get; set; } = default!;
public DbSet<CartItem> CartItems { get; set; } = default!;
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -48,6 +51,17 @@ namespace Webshop.Infrastructure.Data
} }
} }
// +++ NEU: Warenkorb Konfiguration +++
modelBuilder.Entity<Cart>(entity =>
{
// Wenn ein Warenkorb gelöscht wird, lösche alle Items darin automatisch
entity.HasMany(c => c.Items)
.WithOne(i => i.Cart)
.HasForeignKey(i => i.CartId)
.OnDelete(DeleteBehavior.Cascade);
});
// Bestehende Konfigurationen (bleiben unverändert)
modelBuilder.Entity<Productcategorie>().HasKey(pc => new { pc.ProductId, pc.categorieId }); modelBuilder.Entity<Productcategorie>().HasKey(pc => new { pc.ProductId, pc.categorieId });
modelBuilder.Entity<ProductDiscount>().HasKey(pd => new { pd.ProductId, pd.DiscountId }); modelBuilder.Entity<ProductDiscount>().HasKey(pd => new { pd.ProductId, pd.DiscountId });
modelBuilder.Entity<CategorieDiscount>().HasKey(cd => new { cd.categorieId, cd.DiscountId }); modelBuilder.Entity<CategorieDiscount>().HasKey(cd => new { cd.categorieId, cd.DiscountId });
@@ -143,8 +157,8 @@ namespace Webshop.Infrastructure.Data
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ApplicationUser>() modelBuilder.Entity<ApplicationUser>()
.HasOne(a => a.Customer) // Ein ApplicationUser hat ein optionales Customer-Profil .HasOne(a => a.Customer)
.WithOne(c => c.User) // Ein Customer-Profil ist mit genau einem User verknüpft .WithOne(c => c.User)
.HasForeignKey<Customer>(c => c.AspNetUserId); .HasForeignKey<Customer>(c => c.AspNetUserId);
modelBuilder.Entity<ProductDiscount>(entity => modelBuilder.Entity<ProductDiscount>(entity =>
@@ -158,8 +172,6 @@ namespace Webshop.Infrastructure.Data
.HasForeignKey(pd => pd.DiscountId); .HasForeignKey(pd => pd.DiscountId);
}); });
// << NEU: Beziehungskonfiguration für CategorieDiscount >>
modelBuilder.Entity<CategorieDiscount>(entity => modelBuilder.Entity<CategorieDiscount>(entity =>
{ {
entity.HasOne(cd => cd.categorie) entity.HasOne(cd => cd.categorie)
@@ -173,20 +185,16 @@ namespace Webshop.Infrastructure.Data
modelBuilder.Entity<Review>(entity => modelBuilder.Entity<Review>(entity =>
{ {
// Beziehung zu Product
entity.HasOne(r => r.Product) entity.HasOne(r => r.Product)
.WithMany(p => p.Reviews) .WithMany(p => p.Reviews)
.HasForeignKey(r => r.ProductId) .HasForeignKey(r => r.ProductId)
.OnDelete(DeleteBehavior.Cascade); // Lösche Bewertungen, wenn das Produkt gelöscht wird .OnDelete(DeleteBehavior.Cascade);
// Beziehung zu Customer
entity.HasOne(r => r.Customer) entity.HasOne(r => r.Customer)
.WithMany(c => c.Reviews) .WithMany(c => c.Reviews)
.HasForeignKey(r => r.CustomerId) .HasForeignKey(r => r.CustomerId)
.OnDelete(DeleteBehavior.SetNull); // Setze CustomerId auf NULL, wenn der Kunde gelöscht wird .OnDelete(DeleteBehavior.SetNull);
}); });
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Webshop.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class checkoutcart : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Carts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<string>(type: "text", nullable: true),
SessionId = table.Column<string>(type: "text", nullable: true),
LastModified = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Carts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CartItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CartId = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
ProductVariantId = table.Column<Guid>(type: "uuid", nullable: true),
Quantity = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CartItems", x => x.Id);
table.ForeignKey(
name: "FK_CartItems_Carts_CartId",
column: x => x.CartId,
principalTable: "Carts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CartItems_Products_ProductId",
column: x => x.ProductId,
principalTable: "Products",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CartItems_CartId",
table: "CartItems",
column: "CartId");
migrationBuilder.CreateIndex(
name: "IX_CartItems_ProductId",
table: "CartItems",
column: "ProductId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CartItems");
migrationBuilder.DropTable(
name: "Carts");
}
}
}

View File

@@ -215,6 +215,53 @@ namespace Webshop.Infrastructure.Migrations
b.ToTable("Addresses"); b.ToTable("Addresses");
}); });
modelBuilder.Entity("Webshop.Domain.Entities.Cart", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("LastModified")
.HasColumnType("timestamp with time zone");
b.Property<string>("SessionId")
.HasColumnType("text");
b.Property<string>("UserId")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Carts");
});
modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CartId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductVariantId")
.HasColumnType("uuid");
b.Property<int>("Quantity")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CartId");
b.HasIndex("ProductId");
b.ToTable("CartItems");
});
modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1113,6 +1160,25 @@ namespace Webshop.Infrastructure.Migrations
b.Navigation("Customer"); b.Navigation("Customer");
}); });
modelBuilder.Entity("Webshop.Domain.Entities.CartItem", b =>
{
b.HasOne("Webshop.Domain.Entities.Cart", "Cart")
.WithMany("Items")
.HasForeignKey("CartId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Webshop.Domain.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cart");
b.Navigation("Product");
});
modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b =>
{ {
b.HasOne("Webshop.Domain.Entities.Categorie", "Parentcategorie") b.HasOne("Webshop.Domain.Entities.Categorie", "Parentcategorie")
@@ -1311,6 +1377,11 @@ namespace Webshop.Infrastructure.Migrations
b.Navigation("Address"); b.Navigation("Address");
}); });
modelBuilder.Entity("Webshop.Domain.Entities.Cart", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b => modelBuilder.Entity("Webshop.Domain.Entities.Categorie", b =>
{ {
b.Navigation("Productcategories"); b.Navigation("Productcategories");