Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,447 @@
|
||||
using Lagerbestand.Data;
|
||||
using Lagerbestand.Exceptions;
|
||||
using Lagerbestand.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lagerbestand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about storage location occupancy.
|
||||
/// </summary>
|
||||
public record OccupancyStatistics(
|
||||
int TotalLocations,
|
||||
int OccupiedLocations,
|
||||
int AvailableLocations,
|
||||
int BlockedLocations,
|
||||
int TotalByLager,
|
||||
int OccupiedByLager,
|
||||
int TotalByWerkstatt,
|
||||
int OccupiedByWerkstatt,
|
||||
Dictionary<int, int> LocationsByRow,
|
||||
Dictionary<int, int> OccupiedByRow
|
||||
);
|
||||
|
||||
public interface IStorageLocationService
|
||||
{
|
||||
Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false);
|
||||
Task<StorageLocation?> GetByIdAsync(Guid id);
|
||||
Task<StorageLocation?> GetByCodeAsync(string code);
|
||||
Task<List<StorageLocation>> GetAvailableAsync();
|
||||
Task<List<StorageLocation>> GetByAreaAsync(AreaType area);
|
||||
Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row);
|
||||
Task<StorageLocation> CreateAsync(StorageLocation location);
|
||||
Task<StorageLocation> UpdateAsync(StorageLocation location);
|
||||
Task<bool> DeleteAsync(Guid id);
|
||||
Task<List<AreaType>> GetAreasAsync();
|
||||
Task<List<int>> GetRowsAsync(AreaType area);
|
||||
Task<bool> IsLocationAvailableAsync(Guid locationId);
|
||||
Task<int> GetOccupancyAsync(Guid locationId);
|
||||
Task AssignTireStackAsync(string locationCode, Guid tireStackId);
|
||||
Task ClearLocationAsync(string locationCode);
|
||||
Task MoveStackAsync(string fromCode, string toCode);
|
||||
Task<OccupancyStatistics> GetOccupancyStatisticsAsync();
|
||||
Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all default storage locations exist (Werkstatt: 3 rows, Lager: 5 rows,
|
||||
/// each row 9 slots × 4 positions = 288 total). Creates missing, returns number created.
|
||||
/// </summary>
|
||||
Task<int> SeedDefaultLocationsAsync();
|
||||
}
|
||||
|
||||
public class StorageLocationService : IStorageLocationService
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
private readonly ILocationCodeService _locationCodeService;
|
||||
private readonly ILogger<StorageLocationService> _logger;
|
||||
|
||||
public StorageLocationService(
|
||||
AppDbContext context,
|
||||
ILocationCodeService locationCodeService,
|
||||
ILogger<StorageLocationService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_locationCodeService = locationCodeService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false)
|
||||
{
|
||||
var query = _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeInactive)
|
||||
query = query.Where(s => s.IsActive);
|
||||
|
||||
return await query
|
||||
.OrderBy(s => s.Area)
|
||||
.ThenBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<StorageLocation?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
}
|
||||
|
||||
public async Task<StorageLocation?> GetByCodeAsync(string code)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.CatalogItem)
|
||||
.FirstOrDefaultAsync(s => s.Code == code);
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetAvailableAsync()
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.AsNoTracking()
|
||||
.Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
|
||||
.OrderBy(s => s.Area)
|
||||
.ThenBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetByAreaAsync(AreaType area)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Where(s => s.IsActive && s.Area == area)
|
||||
.OrderBy(s => s.Row)
|
||||
.ThenBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Include(s => s.OccupiedTireStack)
|
||||
.ThenInclude(ts => ts!.Customer)
|
||||
.Where(s => s.IsActive && s.Area == area && s.Row == row)
|
||||
.OrderBy(s => s.SlotNumber)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> CreateAsync(StorageLocation location)
|
||||
{
|
||||
// Ensure code is generated from components
|
||||
location.GenerateCode();
|
||||
|
||||
// Validate the generated code
|
||||
var validation = _locationCodeService.ValidateLocationCode(location.Code);
|
||||
if (!validation.IsValid)
|
||||
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
|
||||
|
||||
// Check for duplicate code
|
||||
if (await _locationCodeService.CodeExistsAsync(location.Code))
|
||||
throw new LocationCodeExistsException(location.Code);
|
||||
|
||||
location.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location created: {Code} ({Display})",
|
||||
location.Code, location.DisplayName);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
public async Task<StorageLocation> UpdateAsync(StorageLocation location)
|
||||
{
|
||||
// Regenerate code from components
|
||||
location.GenerateCode();
|
||||
|
||||
// Validate the code
|
||||
var validation = _locationCodeService.ValidateLocationCode(location.Code);
|
||||
if (!validation.IsValid)
|
||||
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
|
||||
|
||||
// Check for duplicate code (excluding this location)
|
||||
var existingWithCode = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(l => l.Code == location.Code && l.Id != location.Id);
|
||||
if (existingWithCode != null)
|
||||
throw new LocationCodeExistsException(location.Code);
|
||||
|
||||
location.UpdatedAt = DateTime.UtcNow;
|
||||
_context.StorageLocations.Update(location);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location updated: {Code}", location.Code);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (location == null)
|
||||
return false;
|
||||
|
||||
// Cannot delete occupied location
|
||||
if (location.IsOccupied)
|
||||
{
|
||||
_logger.LogWarning("Cannot delete storage location {Code} - is occupied", location.Code);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
location.IsActive = false;
|
||||
location.UpdatedAt = DateTime.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Storage location deactivated: {Code}", location.Code);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<AreaType>> GetAreasAsync()
|
||||
{
|
||||
var areas = await _context.StorageLocations
|
||||
.Where(s => s.IsActive)
|
||||
.Select(s => s.Area)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return areas.OrderBy(a => (int)a).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetRowsAsync(AreaType area)
|
||||
{
|
||||
return await _context.StorageLocations
|
||||
.Where(s => s.IsActive && s.Area == area)
|
||||
.Select(s => s.Row)
|
||||
.Distinct()
|
||||
.OrderBy(r => r)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> IsLocationAvailableAsync(Guid locationId)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == locationId);
|
||||
|
||||
if (location == null || !location.IsActive || !location.IsAvailable)
|
||||
return false;
|
||||
|
||||
return !location.IsOccupied;
|
||||
}
|
||||
|
||||
public async Task<int> GetOccupancyAsync(Guid locationId)
|
||||
{
|
||||
var location = await _context.StorageLocations
|
||||
.FirstOrDefaultAsync(s => s.Id == locationId);
|
||||
|
||||
return location?.IsOccupied == true ? 1 : 0;
|
||||
}
|
||||
|
||||
public async Task AssignTireStackAsync(string locationCode, Guid tireStackId)
|
||||
{
|
||||
var location = await GetByCodeAsync(locationCode)
|
||||
?? throw new LocationNotFoundException(locationCode);
|
||||
|
||||
if (location.IsOccupied)
|
||||
throw new LocationOccupiedException(locationCode, location.TireStackId!.Value);
|
||||
|
||||
if (!location.IsAvailable)
|
||||
throw new InvalidOperationException($"Lagerplatz {locationCode} ist gesperrt.");
|
||||
|
||||
location.AssignTireStack(tireStackId);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack {TireStackId} assigned to location {Code}",
|
||||
tireStackId, locationCode);
|
||||
}
|
||||
|
||||
public async Task ClearLocationAsync(string locationCode)
|
||||
{
|
||||
var location = await GetByCodeAsync(locationCode)
|
||||
?? throw new LocationNotFoundException(locationCode);
|
||||
|
||||
var previousTireStackId = location.TireStackId;
|
||||
location.ClearLocation();
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Location {Code} cleared (previous tire stack: {TireStackId})",
|
||||
locationCode, previousTireStackId);
|
||||
}
|
||||
|
||||
public async Task MoveStackAsync(string fromCode, string toCode)
|
||||
{
|
||||
var fromLocation = await GetByCodeAsync(fromCode)
|
||||
?? throw new LocationNotFoundException(fromCode);
|
||||
|
||||
var toLocation = await GetByCodeAsync(toCode)
|
||||
?? throw new LocationNotFoundException(toCode);
|
||||
|
||||
if (!fromLocation.IsOccupied)
|
||||
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
|
||||
|
||||
if (toLocation.IsOccupied)
|
||||
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
|
||||
|
||||
if (!toLocation.IsAvailable)
|
||||
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
|
||||
|
||||
var tireStackId = fromLocation.TireStackId!.Value;
|
||||
|
||||
// Update the tire stack's storage location reference
|
||||
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
|
||||
if (tireStack != null)
|
||||
{
|
||||
tireStack.StorageLocationId = toLocation.Id;
|
||||
tireStack.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Clear source and assign to destination
|
||||
fromLocation.ClearLocation();
|
||||
toLocation.AssignTireStack(tireStackId);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Tire stack {TireStackId} moved from {FromCode} to {ToCode}",
|
||||
tireStackId, fromCode, toCode);
|
||||
}
|
||||
|
||||
public async Task<OccupancyStatistics> GetOccupancyStatisticsAsync()
|
||||
{
|
||||
var locations = await _context.StorageLocations
|
||||
.Where(l => l.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
var locationsByRow = locations
|
||||
.GroupBy(l => l.Row)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var occupiedByRow = locations
|
||||
.Where(l => l.IsOccupied)
|
||||
.GroupBy(l => l.Row)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new OccupancyStatistics(
|
||||
TotalLocations: locations.Count,
|
||||
OccupiedLocations: locations.Count(l => l.IsOccupied),
|
||||
AvailableLocations: locations.Count(l => !l.IsOccupied && l.IsAvailable),
|
||||
BlockedLocations: locations.Count(l => !l.IsAvailable),
|
||||
TotalByLager: locations.Count(l => l.Area == AreaType.Lager),
|
||||
OccupiedByLager: locations.Count(l => l.Area == AreaType.Lager && l.IsOccupied),
|
||||
TotalByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt),
|
||||
OccupiedByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt && l.IsOccupied),
|
||||
LocationsByRow: locationsByRow,
|
||||
OccupiedByRow: occupiedByRow
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot)
|
||||
{
|
||||
var maxRow = area.GetMaxRow();
|
||||
if (row < StorageLocation.MinRow || row > maxRow)
|
||||
throw new ArgumentOutOfRangeException(nameof(row),
|
||||
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
|
||||
|
||||
if (fromSlot < StorageLocation.MinSlot || toSlot > StorageLocation.MaxSlot || fromSlot > toSlot)
|
||||
throw new ArgumentOutOfRangeException(nameof(fromSlot),
|
||||
$"Plätze müssen zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
|
||||
|
||||
var existingCodes = await _context.StorageLocations
|
||||
.Where(l => l.Area == area && l.Row == row)
|
||||
.Select(l => l.Code)
|
||||
.ToListAsync();
|
||||
var existingSet = existingCodes.ToHashSet();
|
||||
|
||||
var createdLocations = new List<StorageLocation>();
|
||||
|
||||
for (int slot = fromSlot; slot <= toSlot; slot++)
|
||||
{
|
||||
foreach (var position in ShelfPositionExtensions.All)
|
||||
{
|
||||
var location = new StorageLocation
|
||||
{
|
||||
Area = area,
|
||||
Row = row,
|
||||
Position = position,
|
||||
SlotNumber = slot
|
||||
};
|
||||
location.GenerateCode();
|
||||
|
||||
if (existingSet.Contains(location.Code))
|
||||
{
|
||||
_logger.LogDebug("Skipping existing location: {Code}", location.Code);
|
||||
continue;
|
||||
}
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
createdLocations.Add(location);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdLocations.Count > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Created {Count} storage locations in {Area} Row {Row}",
|
||||
createdLocations.Count, area.GetDisplayName(), row);
|
||||
}
|
||||
|
||||
return createdLocations;
|
||||
}
|
||||
|
||||
public async Task<int> SeedDefaultLocationsAsync()
|
||||
{
|
||||
var existingCodes = (await _context.StorageLocations
|
||||
.Select(l => l.Code)
|
||||
.ToListAsync()).ToHashSet();
|
||||
|
||||
var created = 0;
|
||||
|
||||
foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
|
||||
{
|
||||
for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
|
||||
{
|
||||
foreach (var position in ShelfPositionExtensions.All)
|
||||
{
|
||||
for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
|
||||
{
|
||||
var location = new StorageLocation
|
||||
{
|
||||
Area = area,
|
||||
Row = row,
|
||||
Position = position,
|
||||
SlotNumber = slot
|
||||
};
|
||||
location.GenerateCode();
|
||||
|
||||
if (existingCodes.Contains(location.Code))
|
||||
continue;
|
||||
|
||||
_context.StorageLocations.Add(location);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Seeded {Count} default storage locations", created);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user