Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,504 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for managing tire stacks
|
||||
/// </summary>
|
||||
public interface ITireStackService
|
||||
{
|
||||
Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory);
|
||||
Task<TireStack?> GetByIdAsync(Guid id);
|
||||
Task<TireStack?> GetByTicketNumberAsync(string ticketNumber);
|
||||
Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null);
|
||||
Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId);
|
||||
Task<List<TireStack>> SearchAsync(string searchTerm);
|
||||
Task<TireStack> CreateAsync(TireStack tireStack);
|
||||
Task<TireStack> UpdateAsync(TireStack tireStack);
|
||||
Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
|
||||
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
|
||||
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
|
||||
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
|
||||
Task<StorageStatistics> GetStatisticsAsync();
|
||||
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for the tire storage system
|
||||
/// </summary>
|
||||
public class StorageStatistics
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of stacks currently stored
|
||||
/// </summary>
|
||||
public int TotalStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of individual tires stored (stacks * 4)
|
||||
/// </summary>
|
||||
public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack;
|
||||
|
||||
/// <summary>
|
||||
/// Total stacks picked up (historical)
|
||||
/// </summary>
|
||||
public int TotalStacksPickedUp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of active customers
|
||||
/// </summary>
|
||||
public int TotalCustomers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of available storage locations
|
||||
/// </summary>
|
||||
public int AvailableLocations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of occupied storage locations
|
||||
/// </summary>
|
||||
public int OccupiedLocations { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Summer tire stacks stored
|
||||
/// </summary>
|
||||
public int SummerStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Winter tire stacks stored
|
||||
/// </summary>
|
||||
public int WinterStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All-season tire stacks stored
|
||||
/// </summary>
|
||||
public int AllSeasonStacksStored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Inventory stacks (shop stock)
|
||||
/// </summary>
|
||||
public int InventoryStackCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Customer tire stacks
|
||||
/// </summary>
|
||||
public int CustomerStackCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for managing tire stacks
|
||||
/// </summary>
|
||||
public class TireStackService : ITireStackService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILogger<TireStackService> _logger;
|
||||
|
||||
public TireStackService(AppDbContext context, ILogger<TireStackService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null)
|
||||
{
|
||||
return await GetAllAsync(status, null);
|
||||
}
|
||||
|
||||
public async Task<List<TireStack>> 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<TireStack?> 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<TireStack?> 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<List<TireStack>> 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<List<TireStack>> 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<List<TireStack>> 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<List<TireStack>> 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<TireStack> 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<TireStack> 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<TireStack> 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<TireStack> 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<TireStack> 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<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null)
|
||||
{
|
||||
var ids = tireStackIds.Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return new List<TireStack>();
|
||||
|
||||
var strategy = _context.Database.CreateExecutionStrategy();
|
||||
try
|
||||
{
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _context.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var pickedUp = new List<TireStack>(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<bool> 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<string> 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<StorageStatistics> 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<List<TireStack>> 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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user