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) 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 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; } }