Files
ReifenSpaeth/Lagerbestand/Services/StorageLocationService.cs
T

449 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 || fromLocation.TireStackId == null)
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
if (toLocation.IsOccupied)
throw toLocation.TireStackId.HasValue
? new LocationOccupiedException(toCode, toLocation.TireStackId.Value)
: new LocationOccupiedException(toCode);
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)
?? throw new InvalidOperationException($"Reifenstapel auf Lagerplatz {fromCode} wurde nicht gefunden.");
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;
}
}