505 lines
19 KiB
C#
505 lines
19 KiB
C#
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();
|
|
}
|
|
}
|