using Lagerbestand.Data;
using Lagerbestand.Exceptions;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
///
/// Statistics about storage location occupancy.
///
public record OccupancyStatistics(
int TotalLocations,
int OccupiedLocations,
int AvailableLocations,
int BlockedLocations,
int TotalByLager,
int OccupiedByLager,
int TotalByWerkstatt,
int OccupiedByWerkstatt,
Dictionary LocationsByRow,
Dictionary OccupiedByRow
);
public interface IStorageLocationService
{
Task> GetAllAsync(bool includeInactive = false);
Task GetByIdAsync(Guid id);
Task GetByCodeAsync(string code);
Task> GetAvailableAsync();
Task> GetByAreaAsync(AreaType area);
Task> GetByRowAsync(AreaType area, int row);
Task CreateAsync(StorageLocation location);
Task UpdateAsync(StorageLocation location);
Task DeleteAsync(Guid id);
Task> GetAreasAsync();
Task> GetRowsAsync(AreaType area);
Task IsLocationAvailableAsync(Guid locationId);
Task GetOccupancyAsync(Guid locationId);
Task AssignTireStackAsync(string locationCode, Guid tireStackId);
Task ClearLocationAsync(string locationCode);
Task MoveStackAsync(string fromCode, string toCode);
Task GetOccupancyStatisticsAsync();
Task> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot);
///
/// 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.
///
Task SeedDefaultLocationsAsync();
}
public class StorageLocationService : IStorageLocationService
{
private readonly AppDbContext _context;
private readonly ILocationCodeService _locationCodeService;
private readonly ILogger _logger;
public StorageLocationService(
AppDbContext context,
ILocationCodeService locationCodeService,
ILogger logger)
{
_context = context;
_locationCodeService = locationCodeService;
_logger = logger;
}
public async Task> 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 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 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> 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> 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> 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 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 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 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> 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> 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 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 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 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> 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();
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 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;
}
}