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();
}
}