using Lagerbestand.Data; using Lagerbestand.Models; using Microsoft.EntityFrameworkCore; namespace Lagerbestand.Services; /// /// Service interface for managing tire stacks /// public interface ITireStackService { Task> GetAllAsync(TireStackStatus? status = null); Task> GetAllAsync(TireStackStatus? status, bool? isInventory); Task GetByIdAsync(Guid id); Task GetByTicketNumberAsync(string ticketNumber); Task> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null); Task> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null); Task> GetByStorageLocationAsync(Guid locationId); Task> SearchAsync(string searchTerm); Task CreateAsync(TireStack tireStack); Task UpdateAsync(TireStack tireStack); Task StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null); Task CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null); Task PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null); Task> PickupBatchAsync(IEnumerable tireStackIds, Guid? pickedUpByUserId = null); Task DeleteAsync(Guid id); Task GenerateTicketNumberAsync(bool isInventory = false); Task GetStatisticsAsync(); Task> GetExpiringPickupsAsync(int daysAhead = 14); } /// /// Statistics for the tire storage system /// public class StorageStatistics { /// /// Total number of stacks currently stored /// public int TotalStacksStored { get; set; } /// /// Total number of individual tires stored (stacks * 4) /// public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack; /// /// Total stacks picked up (historical) /// public int TotalStacksPickedUp { get; set; } /// /// Number of active customers /// public int TotalCustomers { get; set; } /// /// Number of available storage locations /// public int AvailableLocations { get; set; } /// /// Number of occupied storage locations /// public int OccupiedLocations { get; set; } /// /// Summer tire stacks stored /// public int SummerStacksStored { get; set; } /// /// Winter tire stacks stored /// public int WinterStacksStored { get; set; } /// /// All-season tire stacks stored /// public int AllSeasonStacksStored { get; set; } /// /// Inventory stacks (shop stock) /// public int InventoryStackCount { get; set; } /// /// Customer tire stacks /// public int CustomerStackCount { get; set; } } /// /// Service implementation for managing tire stacks /// public class TireStackService : ITireStackService { private readonly AppDbContext _context; private readonly ILogger _logger; public TireStackService(AppDbContext context, ILogger logger) { _context = context; _logger = logger; } public async Task> GetAllAsync(TireStackStatus? status = null) { return await GetAllAsync(status, null); } public async Task> GetAllAsync(TireStackStatus? status, bool? isInventory) { var query = _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.StorageLocation) .Include(ts => ts.CatalogItem) .Where(ts => ts.IsActive); if (status.HasValue) query = query.Where(ts => ts.Status == status.Value); if (isInventory.HasValue) { if (isInventory.Value) query = query.Where(ts => ts.CustomerId == null); else query = query.Where(ts => ts.CustomerId != null); } return await query .OrderByDescending(ts => ts.StoredAt) .ToListAsync(); } public async Task GetByIdAsync(Guid id) { return await _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.StorageLocation) .Include(ts => ts.CatalogItem) .FirstOrDefaultAsync(ts => ts.Id == id && ts.IsActive); } public async Task GetByTicketNumberAsync(string ticketNumber) { return await _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.StorageLocation) .Include(ts => ts.CatalogItem) .FirstOrDefaultAsync(ts => ts.TicketNumber == ticketNumber && ts.IsActive); } public async Task> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null) { var query = _context.TireStacks .Include(ts => ts.StorageLocation) .Include(ts => ts.Vehicle) .Include(ts => ts.CatalogItem) .Where(ts => ts.CustomerId == customerId && ts.IsActive); if (status.HasValue) query = query.Where(ts => ts.Status == status.Value); return await query .OrderByDescending(ts => ts.StoredAt) .ToListAsync(); } public async Task> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null) { var query = _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.StorageLocation) .Include(ts => ts.CatalogItem) .Where(ts => ts.CatalogItemId == catalogItemId && ts.IsActive); if (status.HasValue) query = query.Where(ts => ts.Status == status.Value); return await query .OrderByDescending(ts => ts.StoredAt) .ToListAsync(); } public async Task> GetByStorageLocationAsync(Guid locationId) { return await _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.CatalogItem) .Where(ts => ts.StorageLocationId == locationId && ts.Status == TireStackStatus.Stored && ts.IsActive) .OrderBy(ts => ts.StoredAt) .ToListAsync(); } public async Task> SearchAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) return await GetAllAsync(TireStackStatus.Stored); var term = searchTerm.Trim().ToLower(); return await _context.TireStacks .AsNoTracking() .Include(ts => ts.Customer) .Include(ts => ts.Vehicle) .Include(ts => ts.StorageLocation) .Include(ts => ts.CatalogItem) .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ( ts.TicketNumber.ToLower().Contains(term) || (ts.TireBrand != null && ts.TireBrand.ToLower().Contains(term)) || (ts.TireSize != null && ts.TireSize.ToLower().Contains(term)) || (ts.Customer != null && ts.Customer.FirstName != null && ts.Customer.FirstName.ToLower().Contains(term)) || (ts.Customer != null && ts.Customer.LastName.ToLower().Contains(term)) || (ts.Customer != null && ts.Customer.CustomerNumber.ToLower().Contains(term)) || (ts.Vehicle != null && ts.Vehicle.Make.ToLower().Contains(term)) || (ts.Vehicle != null && ts.Vehicle.Model != null && ts.Vehicle.Model.ToLower().Contains(term)) || (ts.StorageLocation != null && ts.StorageLocation.Code.ToLower().Contains(term)) || (ts.CatalogItem != null && ts.CatalogItem.Name.ToLower().Contains(term)) )) .OrderByDescending(ts => ts.StoredAt) .Take(50) .ToListAsync(); } public async Task CreateAsync(TireStack tireStack) { var isInventory = tireStack.CustomerId == null; if (string.IsNullOrEmpty(tireStack.TicketNumber)) tireStack.TicketNumber = await GenerateTicketNumberAsync(isInventory); tireStack.CreatedAt = DateTime.UtcNow; tireStack.StoredAt = DateTime.UtcNow; _context.TireStacks.Add(tireStack); await _context.SaveChangesAsync(); _logger.LogInformation("Tire stack created: {TicketNumber}, IsInventory: {IsInventory}", tireStack.TicketNumber, isInventory); return tireStack; } public async Task UpdateAsync(TireStack tireStack) { tireStack.UpdatedAt = DateTime.UtcNow; _context.TireStacks.Update(tireStack); await _context.SaveChangesAsync(); _logger.LogInformation("Tire stack updated: {TicketNumber}", tireStack.TicketNumber); return tireStack; } public async Task StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null) { // Verify storage location is available (force fresh values in case a // stale tracked instance exists in this long-lived scoped context) var location = await _context.StorageLocations .FirstOrDefaultAsync(s => s.Id == storageLocationId); if (location == null) throw new InvalidOperationException("Lagerplatz nicht gefunden."); await _context.Entry(location).ReloadAsync(); if (!location.IsActive || !location.IsAvailable) throw new InvalidOperationException("Lagerplatz ist nicht verfügbar."); // AssignTireStack handles the IsOccupied check and sets TireStackId/IsOccupied/OccupiedSince location.AssignTireStack(tireStack.Id); tireStack.StorageLocationId = storageLocationId; tireStack.Status = TireStackStatus.Stored; tireStack.StoredAt = DateTime.UtcNow; tireStack.StoredByUserId = storedByUserId; tireStack.UpdatedAt = DateTime.UtcNow; _context.TireStacks.Update(tireStack); try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { throw new InvalidOperationException($"Lagerplatz {location.Code} wurde soeben anderweitig belegt."); } _logger.LogInformation("Tire stack {TicketNumber} stored at location {LocationCode}", tireStack.TicketNumber, location.Code); return tireStack; } public async Task PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null) { var tireStack = await _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.StorageLocation) .FirstOrDefaultAsync(ts => ts.Id == tireStackId); if (tireStack == null) throw new InvalidOperationException("Reifenstapel nicht gefunden."); if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved) throw new InvalidOperationException("Reifenstapel ist nicht eingelagert."); // Clear the storage location (both sides) if (tireStack.StorageLocation != null) tireStack.StorageLocation.ClearLocation(); tireStack.Status = TireStackStatus.PickedUp; tireStack.PickedUpAt = DateTime.UtcNow; tireStack.PickedUpByUserId = pickedUpByUserId; tireStack.StorageLocationId = null; tireStack.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); _logger.LogInformation("Tire stack {TicketNumber} picked up", tireStack.TicketNumber); return tireStack; } public async Task CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null) { // MySqlRetryingExecutionStrategy requires user transactions to run // inside the execution strategy as a single retriable unit. var hadTicketNumber = !string.IsNullOrEmpty(tireStack.TicketNumber); var strategy = _context.Database.CreateExecutionStrategy(); try { return await strategy.ExecuteAsync(async () => { await using var transaction = await _context.Database.BeginTransactionAsync(); try { var created = await CreateAsync(tireStack); var stored = await StoreAsync(created, storageLocationId, storedByUserId); await transaction.CommitAsync(); return stored; } catch { await transaction.RollbackAsync(); // Detach the failed entities so a retry (by the strategy or // the user) does not re-insert stale Added/Modified state. _context.ChangeTracker.Clear(); // Regenerate the ticket number on retry if we created it here // (avoids permanent unique-index collisions). if (!hadTicketNumber) tireStack.TicketNumber = string.Empty; throw; } }); } catch (DbUpdateConcurrencyException) { throw new InvalidOperationException("Der Lagerplatz wurde soeben anderweitig belegt. Bitte anderen Platz wählen."); } } public async Task> PickupBatchAsync(IEnumerable tireStackIds, Guid? pickedUpByUserId = null) { var ids = tireStackIds.Distinct().ToList(); if (ids.Count == 0) return new List(); var strategy = _context.Database.CreateExecutionStrategy(); try { return await strategy.ExecuteAsync(async () => { await using var transaction = await _context.Database.BeginTransactionAsync(); try { var pickedUp = new List(ids.Count); foreach (var id in ids) { pickedUp.Add(await PickupAsync(id, pickedUpByUserId)); } await transaction.CommitAsync(); return pickedUp; } catch { await transaction.RollbackAsync(); _context.ChangeTracker.Clear(); throw; } }); } catch (DbUpdateConcurrencyException) { throw new InvalidOperationException("Ein Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen."); } } public async Task DeleteAsync(Guid id) { var tireStack = await _context.TireStacks.FindAsync(id); if (tireStack == null) return false; // Block deletion of stored or reserved tire stacks if (tireStack.Status == TireStackStatus.Stored || tireStack.Status == TireStackStatus.Reserved) { _logger.LogWarning("Cannot delete {Status} tire stack {TicketNumber}", tireStack.Status, tireStack.TicketNumber); return false; } // Soft delete tireStack.IsActive = false; tireStack.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); _logger.LogInformation("Tire stack soft-deleted: {TicketNumber}", tireStack.TicketNumber); return true; } public async Task GenerateTicketNumberAsync(bool isInventory = false) { var year = DateTime.UtcNow.Year; var prefix = isInventory ? $"INV-{year}-" : $"TS-{year}-"; var lastNumber = await _context.TireStacks .Where(ts => ts.TicketNumber.StartsWith(prefix)) .OrderByDescending(ts => ts.TicketNumber) .Select(ts => ts.TicketNumber) .FirstOrDefaultAsync(); var nextNumber = 1; if (lastNumber != null) { var numPart = lastNumber.Replace(prefix, ""); if (int.TryParse(numPart, out var lastNum)) nextNumber = lastNum + 1; } return $"{prefix}{nextNumber:D5}"; } public async Task GetStatisticsAsync() { var storedStacks = await _context.TireStacks .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored) .GroupBy(ts => ts.Season) .Select(g => new { Season = g.Key, Count = g.Count() }) .ToListAsync(); var totalLocations = await _context.StorageLocations .Where(s => s.IsActive) .CountAsync(); var occupiedLocations = await _context.TireStacks .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.StorageLocationId != null) .Select(ts => ts.StorageLocationId) .Distinct() .CountAsync(); var inventoryCount = await _context.TireStacks .CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId == null); var customerStackCount = await _context.TireStacks .CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null); return new StorageStatistics { TotalStacksStored = storedStacks.Sum(x => x.Count), TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp), TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive), AvailableLocations = totalLocations - occupiedLocations, OccupiedLocations = occupiedLocations, SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0, WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0, AllSeasonStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Allrad)?.Count ?? 0, InventoryStackCount = inventoryCount, CustomerStackCount = customerStackCount }; } public async Task> GetExpiringPickupsAsync(int daysAhead = 14) { var cutoffDate = DateTime.UtcNow.AddDays(daysAhead); return await _context.TireStacks .Include(ts => ts.Customer) .Include(ts => ts.StorageLocation) .Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null && ts.ExpectedPickupDate != null && ts.ExpectedPickupDate <= cutoffDate) .OrderBy(ts => ts.ExpectedPickupDate) .ToListAsync(); } }