feat: Update service layer for new data model

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>
This commit is contained in:
2026-02-04 23:38:06 -05:00
parent 21cddb22c7
commit c5da5dda98
4 changed files with 484 additions and 80 deletions

View File

@@ -18,7 +18,7 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
builder.Services.AddScoped<MaterialService>();
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<ProjectService>();
builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();

View File

@@ -15,13 +15,22 @@ public class CutListPackingService
_context = context;
}
public async Task<MultiMaterialPackResult> PackAsync(IEnumerable<ProjectPart> parts, decimal kerfInches)
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;
@@ -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<StockBinSource>();
// 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<Bin>();
var toBePurchasedBins = new List<Bin>();
// 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)
{

View File

@@ -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<List<Material>> 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<Material?> 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<Material> CreateAsync(Material material)
@@ -50,6 +40,34 @@ public class MaterialService
return material;
}
/// <summary>
/// Creates a material with dimensions and auto-generates the Size string from dimensions.
/// </summary>
public async Task<Material> 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();
}
/// <summary>
/// Updates a material and its dimensions. Updates the Size string from dimensions if regenerateSize is true.
/// </summary>
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<bool> ExistsAsync(string shape, string size, int? excludeId = null)
public async Task<bool> 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<List<MaterialStockLength>> GetStockLengthsAsync(int materialId)
/// <summary>
/// Search for Round Bar materials by diameter with tolerance.
/// </summary>
public async Task<List<Material>> 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<RoundBarDimensions>()
.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<MaterialStockLength> AddStockLengthAsync(MaterialStockLength stockLength)
/// <summary>
/// Search for Round Tube materials by outer diameter with tolerance.
/// </summary>
public async Task<List<Material>> 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<RoundTubeDimensions>()
.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)
/// <summary>
/// Search for Flat Bar materials by width with tolerance.
/// </summary>
public async Task<List<Material>> SearchFlatBarByWidthAsync(decimal targetWidth, decimal tolerance)
{
_context.MaterialStockLengths.Update(stockLength);
await _context.SaveChangesAsync();
var minValue = targetWidth - tolerance;
var maxValue = targetWidth + tolerance;
return await _context.Set<FlatBarDimensions>()
.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)
/// <summary>
/// Search for Square Bar materials by size with tolerance.
/// </summary>
public async Task<List<Material>> 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<SquareBarDimensions>()
.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();
}
/// <summary>
/// Search for Square Tube materials by size with tolerance.
/// </summary>
public async Task<List<Material>> SearchSquareTubeBySizeAsync(decimal targetSize, decimal tolerance)
{
var minValue = targetSize - tolerance;
var maxValue = targetSize + tolerance;
return await _context.Set<SquareTubeDimensions>()
.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();
}
/// <summary>
/// Search for Rectangular Tube materials by width with tolerance.
/// </summary>
public async Task<List<Material>> SearchRectangularTubeByWidthAsync(decimal targetWidth, decimal tolerance)
{
var minValue = targetWidth - tolerance;
var maxValue = targetWidth + tolerance;
return await _context.Set<RectangularTubeDimensions>()
.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();
}
/// <summary>
/// Search for Angle materials by leg size with tolerance.
/// </summary>
public async Task<List<Material>> SearchAngleByLegAsync(decimal targetLeg, decimal tolerance)
{
var minValue = targetLeg - tolerance;
var maxValue = targetLeg + tolerance;
return await _context.Set<AngleDimensions>()
.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();
}
/// <summary>
/// Search for Channel materials by height with tolerance.
/// </summary>
public async Task<List<Material>> SearchChannelByHeightAsync(decimal targetHeight, decimal tolerance)
{
var minValue = targetHeight - tolerance;
var maxValue = targetHeight + tolerance;
return await _context.Set<ChannelDimensions>()
.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();
}
/// <summary>
/// Search for I-Beam materials by height with tolerance.
/// </summary>
public async Task<List<Material>> SearchIBeamByHeightAsync(decimal targetHeight, decimal tolerance)
{
var minValue = targetHeight - tolerance;
var maxValue = targetHeight + tolerance;
return await _context.Set<IBeamDimensions>()
.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();
}
/// <summary>
/// Search for Pipe materials by nominal size with tolerance.
/// </summary>
public async Task<List<Material>> SearchPipeByNominalSizeAsync(decimal targetNPS, decimal tolerance)
{
var minValue = targetNPS - tolerance;
var maxValue = targetNPS + tolerance;
return await _context.Set<PipeDimensions>()
.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();
}
/// <summary>
/// Gets materials filtered by shape.
/// </summary>
public async Task<List<Material>> 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<bool> StockLengthExistsAsync(int materialId, decimal lengthInches, int? excludeId = null)
/// <summary>
/// Creates the appropriate dimension object for a given shape.
/// </summary>
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}")
};
}

View File

@@ -96,4 +96,162 @@ public class StockItemService
return await query.AnyAsync();
}
// Stock transaction methods
public async Task<StockTransaction> 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<decimal?> 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<decimal?> 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<StockTransaction> 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<StockTransaction> 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<StockTransaction> 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<List<StockTransaction>> 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<int> 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;
}
}