448 lines
16 KiB
C#
448 lines
16 KiB
C#
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;
|
||
}
|
||
}
|