diff --git a/CutList.Web/Program.cs b/CutList.Web/Program.cs index ec05740..1fecae3 100644 --- a/CutList.Web/Program.cs +++ b/CutList.Web/Program.cs @@ -18,7 +18,7 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/CutList.Web/Services/CutListPackingService.cs b/CutList.Web/Services/CutListPackingService.cs index 06ddb64..c1fa316 100644 --- a/CutList.Web/Services/CutListPackingService.cs +++ b/CutList.Web/Services/CutListPackingService.cs @@ -15,13 +15,22 @@ public class CutListPackingService _context = context; } - public async Task PackAsync(IEnumerable parts, decimal kerfInches) + public async Task PackAsync(IEnumerable parts, decimal kerfInches) + { + return await PackAsync(parts, kerfInches, null); + } + + public async Task PackAsync(IEnumerable parts, decimal kerfInches, IEnumerable? jobStock) { var result = new MultiMaterialPackResult(); // Group parts by material var partsByMaterial = parts.GroupBy(p => p.MaterialId); + // Group job stock by material for quick lookup + var jobStockByMaterial = jobStock?.GroupBy(s => s.MaterialId) + .ToDictionary(g => g.Key, g => g.ToList()) ?? new Dictionary>(); + foreach (var group in partsByMaterial) { var materialId = group.Key; @@ -33,42 +42,49 @@ public class CutListPackingService if (material == null) continue; - // Get in-stock lengths for this material - var inStockLengths = await _context.MaterialStockLengths - .Where(s => s.MaterialId == materialId && s.IsActive && s.Quantity > 0) - .ToListAsync(); - - // Get stock item lengths for this material (for purchase) - var stockItemLengths = await _context.StockItems - .Where(s => s.MaterialId == materialId && s.IsActive) - .Select(s => s.LengthInches) - .Distinct() - .ToListAsync(); - - // Build stock bins: in-stock first (priority 1), then supplier stock (priority 2) + // Build stock bins var stockBins = new List(); - // In-stock bins with finite quantity - foreach (var stock in inStockLengths) + // Check if job has specific stock configured for this material + if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0) { - stockBins.Add(new StockBinSource - { - LengthInches = stock.LengthInches, - Quantity = stock.Quantity, - Priority = 1, - IsInStock = true - }); - } - - // Stock item bins with unlimited quantity - foreach (var length in stockItemLengths) - { - // Only add if not already covered by in-stock - if (!stockBins.Any(b => b.LengthInches == length && b.IsInStock)) + // Use job-specific stock configuration + foreach (var stock in materialJobStock.OrderBy(s => s.Priority)) { stockBins.Add(new StockBinSource { - LengthInches = length, + LengthInches = stock.LengthInches, + Quantity = stock.Quantity, + Priority = stock.Priority, + IsInStock = !stock.IsCustomLength && stock.StockItemId.HasValue + }); + } + } + else + { + // No job-specific stock - use all available stock items for this material + var stockItems = await _context.StockItems + .Where(s => s.MaterialId == materialId && s.IsActive) + .ToListAsync(); + + foreach (var stock in stockItems) + { + if (stock.QuantityOnHand > 0) + { + // In-stock with finite quantity + stockBins.Add(new StockBinSource + { + LengthInches = stock.LengthInches, + Quantity = stock.QuantityOnHand, + Priority = 1, + IsInStock = true + }); + } + + // Always add as purchasable (unlimited) - algorithm will use in-stock first due to priority + stockBins.Add(new StockBinSource + { + LengthInches = stock.LengthInches, Quantity = -1, // unlimited Priority = 2, IsInStock = false @@ -122,10 +138,11 @@ public class CutListPackingService var inStockBins = new List(); var toBePurchasedBins = new List(); - // Track remaining in-stock quantities - var remainingStock = inStockLengths.ToDictionary( - s => s.LengthInches, - s => s.Quantity); + // Track remaining in-stock quantities from the stock bins we configured + var remainingStock = stockBins + .Where(s => s.IsInStock && s.Quantity > 0) + .GroupBy(s => s.LengthInches) + .ToDictionary(g => g.Key, g => g.Sum(s => s.Quantity)); foreach (var bin in packResult.Bins) { diff --git a/CutList.Web/Services/MaterialService.cs b/CutList.Web/Services/MaterialService.cs index 0825661..cf9caf8 100644 --- a/CutList.Web/Services/MaterialService.cs +++ b/CutList.Web/Services/MaterialService.cs @@ -8,20 +8,6 @@ public class MaterialService { private readonly ApplicationDbContext _context; - public static readonly string[] CommonShapes = - { - "Round Tube", - "Square Tube", - "Rectangular Tube", - "Angle", - "Channel", - "Flat Bar", - "Round Bar", - "Square Bar", - "I-Beam", - "Pipe" - }; - public MaterialService(ApplicationDbContext context) { _context = context; @@ -29,17 +15,21 @@ public class MaterialService public async Task> GetAllAsync(bool includeInactive = false) { - var query = _context.Materials.AsQueryable(); + var query = _context.Materials + .Include(m => m.Dimensions) + .AsQueryable(); if (!includeInactive) { query = query.Where(m => m.IsActive); } - return await query.OrderBy(m => m.Shape).ThenBy(m => m.Size).ToListAsync(); + return await query.OrderBy(m => m.Shape).ThenBy(m => m.SortOrder).ThenBy(m => m.Size).ToListAsync(); } public async Task GetByIdAsync(int id) { - return await _context.Materials.FindAsync(id); + return await _context.Materials + .Include(m => m.Dimensions) + .FirstOrDefaultAsync(m => m.Id == id); } public async Task CreateAsync(Material material) @@ -50,6 +40,34 @@ public class MaterialService return material; } + /// + /// Creates a material with dimensions and auto-generates the Size string from dimensions. + /// + public async Task CreateWithDimensionsAsync(Material material, MaterialDimensions dimensions) + { + material.CreatedAt = DateTime.UtcNow; + + // Auto-generate Size string from dimensions if not provided + if (string.IsNullOrWhiteSpace(material.Size)) + { + material.Size = dimensions.GenerateSizeString(); + } + + // Set sort order from primary dimension + material.SortOrder = dimensions.GetSortOrder(); + + _context.Materials.Add(material); + await _context.SaveChangesAsync(); + + // Link dimensions to the created material + dimensions.MaterialId = material.Id; + _context.MaterialDimensions.Add(dimensions); + await _context.SaveChangesAsync(); + + material.Dimensions = dimensions; + return material; + } + public async Task UpdateAsync(Material material) { material.UpdatedAt = DateTime.UtcNow; @@ -57,6 +75,55 @@ public class MaterialService await _context.SaveChangesAsync(); } + /// + /// Updates a material and its dimensions. Updates the Size string from dimensions if regenerateSize is true. + /// + public async Task UpdateWithDimensionsAsync(Material material, MaterialDimensions dimensions, bool regenerateSize = false) + { + material.UpdatedAt = DateTime.UtcNow; + + if (regenerateSize) + { + material.Size = dimensions.GenerateSizeString(); + } + + // Update sort order from primary dimension + material.SortOrder = dimensions.GetSortOrder(); + + // Ensure the dimensions have the correct MaterialId + dimensions.MaterialId = material.Id; + + // If the dimensions entity already has an Id (was loaded from DB), just mark it as modified + // Otherwise, check if dimensions exist and handle appropriately + if (dimensions.Id > 0) + { + // Already tracked, just save + _context.Entry(material).State = EntityState.Modified; + } + else + { + _context.Materials.Update(material); + + // Check if dimensions already exist for this material + var existingDimensions = await _context.MaterialDimensions + .AsNoTracking() + .FirstOrDefaultAsync(d => d.MaterialId == material.Id); + + if (existingDimensions != null) + { + // Copy the existing Id to update in place + dimensions.Id = existingDimensions.Id; + _context.MaterialDimensions.Update(dimensions); + } + else + { + _context.MaterialDimensions.Add(dimensions); + } + } + + await _context.SaveChangesAsync(); + } + public async Task DeleteAsync(int id) { var material = await _context.Materials.FindAsync(id); @@ -67,7 +134,7 @@ public class MaterialService } } - public async Task ExistsAsync(string shape, string size, int? excludeId = null) + public async Task ExistsAsync(MaterialShape shape, string size, int? excludeId = null) { var query = _context.Materials.Where(m => m.Shape == shape && m.Size == size && m.IsActive); if (excludeId.HasValue) @@ -77,46 +144,208 @@ public class MaterialService return await query.AnyAsync(); } - // Stock Length methods - public async Task> GetStockLengthsAsync(int materialId) + /// + /// Search for Round Bar materials by diameter with tolerance. + /// + public async Task> SearchRoundBarByDiameterAsync(decimal targetDiameter, decimal tolerance) { - return await _context.MaterialStockLengths - .Where(s => s.MaterialId == materialId && s.IsActive) - .OrderBy(s => s.LengthInches) + var minValue = targetDiameter - tolerance; + var maxValue = targetDiameter + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Diameter >= minValue && d.Diameter <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) .ToListAsync(); } - public async Task AddStockLengthAsync(MaterialStockLength stockLength) + /// + /// Search for Round Tube materials by outer diameter with tolerance. + /// + public async Task> SearchRoundTubeByODAsync(decimal targetOD, decimal tolerance) { - _context.MaterialStockLengths.Add(stockLength); - await _context.SaveChangesAsync(); - return stockLength; + var minValue = targetOD - tolerance; + var maxValue = targetOD + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.OuterDiameter >= minValue && d.OuterDiameter <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); } - public async Task UpdateStockLengthAsync(MaterialStockLength stockLength) + /// + /// Search for Flat Bar materials by width with tolerance. + /// + public async Task> SearchFlatBarByWidthAsync(decimal targetWidth, decimal tolerance) { - _context.MaterialStockLengths.Update(stockLength); - await _context.SaveChangesAsync(); + var minValue = targetWidth - tolerance; + var maxValue = targetWidth + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Width >= minValue && d.Width <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); } - public async Task DeleteStockLengthAsync(int id) + /// + /// Search for Square Bar materials by size with tolerance. + /// + public async Task> SearchSquareBarBySizeAsync(decimal targetSize, decimal tolerance) { - var stockLength = await _context.MaterialStockLengths.FindAsync(id); - if (stockLength != null) + var minValue = targetSize - tolerance; + var maxValue = targetSize + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Size >= minValue && d.Size <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for Square Tube materials by size with tolerance. + /// + public async Task> SearchSquareTubeBySizeAsync(decimal targetSize, decimal tolerance) + { + var minValue = targetSize - tolerance; + var maxValue = targetSize + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Size >= minValue && d.Size <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for Rectangular Tube materials by width with tolerance. + /// + public async Task> SearchRectangularTubeByWidthAsync(decimal targetWidth, decimal tolerance) + { + var minValue = targetWidth - tolerance; + var maxValue = targetWidth + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Width >= minValue && d.Width <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for Angle materials by leg size with tolerance. + /// + public async Task> SearchAngleByLegAsync(decimal targetLeg, decimal tolerance) + { + var minValue = targetLeg - tolerance; + var maxValue = targetLeg + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Leg1 >= minValue && d.Leg1 <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for Channel materials by height with tolerance. + /// + public async Task> SearchChannelByHeightAsync(decimal targetHeight, decimal tolerance) + { + var minValue = targetHeight - tolerance; + var maxValue = targetHeight + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Height >= minValue && d.Height <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for I-Beam materials by height with tolerance. + /// + public async Task> SearchIBeamByHeightAsync(decimal targetHeight, decimal tolerance) + { + var minValue = targetHeight - tolerance; + var maxValue = targetHeight + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.Height >= minValue && d.Height <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Search for Pipe materials by nominal size with tolerance. + /// + public async Task> SearchPipeByNominalSizeAsync(decimal targetNPS, decimal tolerance) + { + var minValue = targetNPS - tolerance; + var maxValue = targetNPS + tolerance; + + return await _context.Set() + .Include(d => d.Material) + .Where(d => d.Material.IsActive) + .Where(d => d.NominalSize >= minValue && d.NominalSize <= maxValue) + .Select(d => d.Material) + .OrderBy(m => m.Size) + .ToListAsync(); + } + + /// + /// Gets materials filtered by shape. + /// + public async Task> GetByShapeAsync(MaterialShape shape, bool includeInactive = false) + { + var query = _context.Materials + .Include(m => m.Dimensions) + .Where(m => m.Shape == shape); + + if (!includeInactive) { - _context.MaterialStockLengths.Remove(stockLength); - await _context.SaveChangesAsync(); + query = query.Where(m => m.IsActive); } + + return await query.OrderBy(m => m.Size).ToListAsync(); } - public async Task StockLengthExistsAsync(int materialId, decimal lengthInches, int? excludeId = null) + /// + /// Creates the appropriate dimension object for a given shape. + /// + public static MaterialDimensions CreateDimensionsForShape(MaterialShape shape) => shape switch { - var query = _context.MaterialStockLengths - .Where(s => s.MaterialId == materialId && s.LengthInches == lengthInches && s.IsActive); - if (excludeId.HasValue) - { - query = query.Where(s => s.Id != excludeId.Value); - } - return await query.AnyAsync(); - } + MaterialShape.RoundBar => new RoundBarDimensions(), + MaterialShape.RoundTube => new RoundTubeDimensions(), + MaterialShape.FlatBar => new FlatBarDimensions(), + MaterialShape.SquareBar => new SquareBarDimensions(), + MaterialShape.SquareTube => new SquareTubeDimensions(), + MaterialShape.RectangularTube => new RectangularTubeDimensions(), + MaterialShape.Angle => new AngleDimensions(), + MaterialShape.Channel => new ChannelDimensions(), + MaterialShape.IBeam => new IBeamDimensions(), + MaterialShape.Pipe => new PipeDimensions(), + _ => throw new ArgumentException($"Unknown shape: {shape}") + }; } diff --git a/CutList.Web/Services/StockItemService.cs b/CutList.Web/Services/StockItemService.cs index a00c4bb..2eba0c3 100644 --- a/CutList.Web/Services/StockItemService.cs +++ b/CutList.Web/Services/StockItemService.cs @@ -96,4 +96,162 @@ public class StockItemService return await query.AnyAsync(); } + + // Stock transaction methods + public async Task AddStockAsync(int stockItemId, int quantity, int? supplierId = null, decimal? unitPrice = null, string? notes = null) + { + var stockItem = await _context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = quantity, + Type = StockTransactionType.Received, + SupplierId = supplierId, + UnitPrice = unitPrice, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand += quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + _context.StockTransactions.Add(transaction); + await _context.SaveChangesAsync(); + + return transaction; + } + + public async Task GetAverageCostAsync(int stockItemId) + { + var transactions = await _context.StockTransactions + .Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue) + .ToListAsync(); + + if (transactions.Count == 0) + return null; + + var totalCost = transactions.Sum(t => t.Quantity * t.UnitPrice!.Value); + var totalQty = transactions.Sum(t => t.Quantity); + + return totalQty > 0 ? totalCost / totalQty : null; + } + + public async Task GetLastPurchasePriceAsync(int stockItemId) + { + return await _context.StockTransactions + .Where(t => t.StockItemId == stockItemId && t.Type == StockTransactionType.Received && t.UnitPrice.HasValue) + .OrderByDescending(t => t.CreatedAt) + .Select(t => t.UnitPrice) + .FirstOrDefaultAsync(); + } + + public async Task UseStockAsync(int stockItemId, int quantity, int? jobId = null, string? notes = null) + { + var stockItem = await _context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = -quantity, + Type = StockTransactionType.Used, + JobId = jobId, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand -= quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + _context.StockTransactions.Add(transaction); + await _context.SaveChangesAsync(); + + return transaction; + } + + public async Task AdjustStockAsync(int stockItemId, int newQuantity, string? notes = null) + { + var stockItem = await _context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var difference = newQuantity - stockItem.QuantityOnHand; + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = difference, + Type = StockTransactionType.Adjustment, + Notes = notes ?? "Manual adjustment", + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand = newQuantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + _context.StockTransactions.Add(transaction); + await _context.SaveChangesAsync(); + + return transaction; + } + + public async Task ScrapStockAsync(int stockItemId, int quantity, string? notes = null) + { + var stockItem = await _context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var transaction = new StockTransaction + { + StockItemId = stockItemId, + Quantity = -quantity, + Type = StockTransactionType.Scrapped, + Notes = notes, + CreatedAt = DateTime.UtcNow + }; + + stockItem.QuantityOnHand -= quantity; + stockItem.UpdatedAt = DateTime.UtcNow; + + _context.StockTransactions.Add(transaction); + await _context.SaveChangesAsync(); + + return transaction; + } + + public async Task> GetTransactionHistoryAsync(int stockItemId, int? limit = null) + { + var query = _context.StockTransactions + .Include(t => t.Job) + .Include(t => t.Supplier) + .Where(t => t.StockItemId == stockItemId) + .OrderByDescending(t => t.CreatedAt) + .AsQueryable(); + + if (limit.HasValue) + { + query = query.Take(limit.Value); + } + + return await query.ToListAsync(); + } + + public async Task RecalculateQuantityAsync(int stockItemId) + { + var stockItem = await _context.StockItems.FindAsync(stockItemId) + ?? throw new InvalidOperationException($"Stock item {stockItemId} not found"); + + var calculatedQuantity = await _context.StockTransactions + .Where(t => t.StockItemId == stockItemId) + .SumAsync(t => t.Quantity); + + if (stockItem.QuantityOnHand != calculatedQuantity) + { + stockItem.QuantityOnHand = calculatedQuantity; + stockItem.UpdatedAt = DateTime.UtcNow; + await _context.SaveChangesAsync(); + } + + return calculatedQuantity; + } }