MaterialService: - Include Dimensions in queries - Add CreateWithDimensionsAsync for typed dimension creation - Add UpdateWithDimensionsAsync with optional size regeneration - Add dimension search methods by value with tolerance - Sort by SortOrder for numeric ordering StockItemService: - Add stock transaction methods (AddStock, UseStock, AdjustStock) - Add GetAverageCost and GetLastPurchasePrice for costing - Add GetTransactionHistory for audit CutListPackingService: - Update to use JobPart instead of ProjectPart - Support job-specific stock (JobStock) with priorities - Fall back to all available stock when no job stock configured Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
278 lines
9.8 KiB
C#
278 lines
9.8 KiB
C#
using CutList.Core;
|
|
using CutList.Core.Nesting;
|
|
using CutList.Web.Data;
|
|
using CutList.Web.Data.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace CutList.Web.Services;
|
|
|
|
public class CutListPackingService
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public CutListPackingService(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<MultiMaterialPackResult> PackAsync(IEnumerable<JobPart> parts, decimal kerfInches)
|
|
{
|
|
return await PackAsync(parts, kerfInches, null);
|
|
}
|
|
|
|
public async Task<MultiMaterialPackResult> PackAsync(IEnumerable<JobPart> parts, decimal kerfInches, IEnumerable<JobStock>? 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<int, List<JobStock>>();
|
|
|
|
foreach (var group in partsByMaterial)
|
|
{
|
|
var materialId = group.Key;
|
|
var materialParts = group.ToList();
|
|
|
|
// Get the material
|
|
var material = await _context.Materials
|
|
.FirstOrDefaultAsync(m => m.Id == materialId);
|
|
|
|
if (material == null) continue;
|
|
|
|
// Build stock bins
|
|
var stockBins = new List<StockBinSource>();
|
|
|
|
// Check if job has specific stock configured for this material
|
|
if (jobStockByMaterial.TryGetValue(materialId, out var materialJobStock) && materialJobStock.Count > 0)
|
|
{
|
|
// Use job-specific stock configuration
|
|
foreach (var stock in materialJobStock.OrderBy(s => s.Priority))
|
|
{
|
|
stockBins.Add(new StockBinSource
|
|
{
|
|
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
|
|
});
|
|
}
|
|
}
|
|
|
|
if (stockBins.Count == 0)
|
|
{
|
|
// No stock available for this material - mark all parts as not placed
|
|
var materialResult = new MaterialPackResult
|
|
{
|
|
Material = material,
|
|
PackResult = new PackResult(),
|
|
InStockBins = new List<Bin>(),
|
|
ToBePurchasedBins = new List<Bin>()
|
|
};
|
|
|
|
// Add all parts as not used
|
|
foreach (var part in materialParts)
|
|
{
|
|
for (int i = 0; i < part.Quantity; i++)
|
|
{
|
|
materialResult.PackResult.AddItemNotUsed(new BinItem(part.Name, (double)part.LengthInches));
|
|
}
|
|
}
|
|
|
|
result.MaterialResults.Add(materialResult);
|
|
continue;
|
|
}
|
|
|
|
// Run the packing algorithm
|
|
var engine = new MultiBinEngine();
|
|
engine.Spacing = (double)kerfInches;
|
|
engine.Strategy = PackingStrategy.AdvancedFit;
|
|
|
|
var multiBins = stockBins
|
|
.Select(b => new MultiBin((double)b.LengthInches, b.Quantity, b.Priority))
|
|
.ToList();
|
|
|
|
engine.SetBins(multiBins);
|
|
|
|
var items = materialParts
|
|
.SelectMany(p => Enumerable.Range(0, p.Quantity)
|
|
.Select(_ => new BinItem(p.Name, (double)p.LengthInches)))
|
|
.ToList();
|
|
|
|
var packResult = engine.Pack(items);
|
|
|
|
// Separate bins into in-stock and to-be-purchased
|
|
var inStockBins = new List<Bin>();
|
|
var toBePurchasedBins = new List<Bin>();
|
|
|
|
// 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)
|
|
{
|
|
var binLength = (decimal)bin.Length;
|
|
|
|
// Check if this can come from in-stock
|
|
if (remainingStock.TryGetValue(binLength, out var remaining) && remaining > 0)
|
|
{
|
|
inStockBins.Add(bin);
|
|
remainingStock[binLength] = remaining - 1;
|
|
}
|
|
else
|
|
{
|
|
toBePurchasedBins.Add(bin);
|
|
}
|
|
}
|
|
|
|
result.MaterialResults.Add(new MaterialPackResult
|
|
{
|
|
Material = material,
|
|
PackResult = packResult,
|
|
InStockBins = inStockBins,
|
|
ToBePurchasedBins = toBePurchasedBins
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public MultiMaterialPackingSummary GetSummary(MultiMaterialPackResult result)
|
|
{
|
|
var summary = new MultiMaterialPackingSummary();
|
|
|
|
foreach (var materialResult in result.MaterialResults)
|
|
{
|
|
var materialSummary = new MaterialPackingSummary
|
|
{
|
|
Material = materialResult.Material
|
|
};
|
|
|
|
foreach (var bin in materialResult.InStockBins)
|
|
{
|
|
materialSummary.InStockBins++;
|
|
materialSummary.TotalMaterial += bin.Length;
|
|
materialSummary.TotalUsed += bin.UsedLength;
|
|
materialSummary.TotalWaste += bin.RemainingLength;
|
|
materialSummary.TotalPieces += bin.Items.Count;
|
|
}
|
|
|
|
foreach (var bin in materialResult.ToBePurchasedBins)
|
|
{
|
|
materialSummary.ToBePurchasedBins++;
|
|
materialSummary.TotalMaterial += bin.Length;
|
|
materialSummary.TotalUsed += bin.UsedLength;
|
|
materialSummary.TotalWaste += bin.RemainingLength;
|
|
materialSummary.TotalPieces += bin.Items.Count;
|
|
}
|
|
|
|
materialSummary.ItemsNotPlaced = materialResult.PackResult.ItemsNotUsed.Count;
|
|
|
|
if (materialSummary.TotalMaterial > 0)
|
|
{
|
|
materialSummary.Efficiency = materialSummary.TotalUsed / materialSummary.TotalMaterial * 100;
|
|
}
|
|
|
|
summary.MaterialSummaries.Add(materialSummary);
|
|
|
|
// Aggregate totals
|
|
summary.TotalInStockBins += materialSummary.InStockBins;
|
|
summary.TotalToBePurchasedBins += materialSummary.ToBePurchasedBins;
|
|
summary.TotalPieces += materialSummary.TotalPieces;
|
|
summary.TotalMaterial += materialSummary.TotalMaterial;
|
|
summary.TotalUsed += materialSummary.TotalUsed;
|
|
summary.TotalWaste += materialSummary.TotalWaste;
|
|
summary.TotalItemsNotPlaced += materialSummary.ItemsNotPlaced;
|
|
}
|
|
|
|
if (summary.TotalMaterial > 0)
|
|
{
|
|
summary.Efficiency = summary.TotalUsed / summary.TotalMaterial * 100;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
}
|
|
|
|
public class StockBinSource
|
|
{
|
|
public decimal LengthInches { get; set; }
|
|
public int Quantity { get; set; }
|
|
public int Priority { get; set; }
|
|
public bool IsInStock { get; set; }
|
|
}
|
|
|
|
public class MultiMaterialPackResult
|
|
{
|
|
public List<MaterialPackResult> MaterialResults { get; set; } = new();
|
|
}
|
|
|
|
public class MaterialPackResult
|
|
{
|
|
public Material Material { get; set; } = null!;
|
|
public PackResult PackResult { get; set; } = null!;
|
|
public List<Bin> InStockBins { get; set; } = new();
|
|
public List<Bin> ToBePurchasedBins { get; set; } = new();
|
|
}
|
|
|
|
public class MultiMaterialPackingSummary
|
|
{
|
|
public List<MaterialPackingSummary> MaterialSummaries { get; set; } = new();
|
|
public int TotalInStockBins { get; set; }
|
|
public int TotalToBePurchasedBins { get; set; }
|
|
public int TotalPieces { get; set; }
|
|
public double TotalMaterial { get; set; }
|
|
public double TotalUsed { get; set; }
|
|
public double TotalWaste { get; set; }
|
|
public double Efficiency { get; set; }
|
|
public int TotalItemsNotPlaced { get; set; }
|
|
}
|
|
|
|
public class MaterialPackingSummary
|
|
{
|
|
public Material Material { get; set; } = null!;
|
|
public int InStockBins { get; set; }
|
|
public int ToBePurchasedBins { get; set; }
|
|
public int TotalPieces { get; set; }
|
|
public double TotalMaterial { get; set; }
|
|
public double TotalUsed { get; set; }
|
|
public double TotalWaste { get; set; }
|
|
public double Efficiency { get; set; }
|
|
public int ItemsNotPlaced { get; set; }
|
|
}
|