diff --git a/CutList.Web/Controllers/CuttingToolsController.cs b/CutList.Web/Controllers/CuttingToolsController.cs new file mode 100644 index 0000000..8c329b3 --- /dev/null +++ b/CutList.Web/Controllers/CuttingToolsController.cs @@ -0,0 +1,97 @@ +using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/cutting-tools")] +public class CuttingToolsController : ControllerBase +{ + private readonly JobService _jobService; + + public CuttingToolsController(JobService jobService) + { + _jobService = jobService; + } + + [HttpGet] + public async Task>> GetAll([FromQuery] bool includeInactive = false) + { + var tools = await _jobService.GetCuttingToolsAsync(includeInactive); + return Ok(tools.Select(MapToDto).ToList()); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var tool = await _jobService.GetCuttingToolByIdAsync(id); + if (tool == null) + return NotFound(); + + return Ok(MapToDto(tool)); + } + + [HttpGet("default")] + public async Task> GetDefault() + { + var tool = await _jobService.GetDefaultCuttingToolAsync(); + if (tool == null) + return NotFound(); + + return Ok(MapToDto(tool)); + } + + [HttpPost] + public async Task> Create(CreateCuttingToolDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + return BadRequest("Name is required"); + + var tool = new CuttingTool + { + Name = dto.Name, + KerfInches = dto.KerfInches, + IsDefault = dto.IsDefault + }; + + await _jobService.CreateCuttingToolAsync(tool); + return CreatedAtAction(nameof(GetById), new { id = tool.Id }, MapToDto(tool)); + } + + [HttpPut("{id}")] + public async Task> Update(int id, UpdateCuttingToolDto dto) + { + var tool = await _jobService.GetCuttingToolByIdAsync(id); + if (tool == null) + return NotFound(); + + if (dto.Name != null) tool.Name = dto.Name; + if (dto.KerfInches.HasValue) tool.KerfInches = dto.KerfInches.Value; + if (dto.IsDefault.HasValue) tool.IsDefault = dto.IsDefault.Value; + + await _jobService.UpdateCuttingToolAsync(tool); + return Ok(MapToDto(tool)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var tool = await _jobService.GetCuttingToolByIdAsync(id); + if (tool == null) + return NotFound(); + + await _jobService.DeleteCuttingToolAsync(id); + return NoContent(); + } + + private static CuttingToolDto MapToDto(CuttingTool tool) => new() + { + Id = tool.Id, + Name = tool.Name, + KerfInches = tool.KerfInches, + IsDefault = tool.IsDefault, + IsActive = tool.IsActive + }; +} diff --git a/CutList.Web/Controllers/JobsController.cs b/CutList.Web/Controllers/JobsController.cs new file mode 100644 index 0000000..4ec8465 --- /dev/null +++ b/CutList.Web/Controllers/JobsController.cs @@ -0,0 +1,492 @@ +using CutList.Core.Formatting; +using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class JobsController : ControllerBase +{ + private readonly JobService _jobService; + private readonly CutListPackingService _packingService; + + public JobsController(JobService jobService, CutListPackingService packingService) + { + _jobService = jobService; + _packingService = packingService; + } + + [HttpGet] + public async Task>> GetAll() + { + var jobs = await _jobService.GetAllAsync(); + return Ok(jobs.Select(MapToDto).ToList()); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + return Ok(MapToDetailDto(job)); + } + + [HttpPost] + public async Task> Create(CreateJobDto dto) + { + var job = new Job + { + Name = dto.Name, + Customer = dto.Customer, + CuttingToolId = dto.CuttingToolId, + Notes = dto.Notes + }; + + await _jobService.CreateAsync(job); + var created = await _jobService.GetByIdAsync(job.Id); + return CreatedAtAction(nameof(GetById), new { id = job.Id }, MapToDetailDto(created!)); + } + + [HttpPost("quick-create")] + public async Task> QuickCreate(QuickCreateJobDto dto) + { + var job = await _jobService.QuickCreateAsync(dto.Customer); + var created = await _jobService.GetByIdAsync(job.Id); + return CreatedAtAction(nameof(GetById), new { id = job.Id }, MapToDetailDto(created!)); + } + + [HttpPut("{id}")] + public async Task> Update(int id, UpdateJobDto dto) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + if (dto.Name != null) job.Name = dto.Name; + if (dto.Customer != null) job.Customer = dto.Customer; + if (dto.CuttingToolId.HasValue) job.CuttingToolId = dto.CuttingToolId; + if (dto.Notes != null) job.Notes = dto.Notes; + + await _jobService.UpdateAsync(job); + + var updated = await _jobService.GetByIdAsync(id); + return Ok(MapToDetailDto(updated!)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + await _jobService.DeleteAsync(id); + return NoContent(); + } + + [HttpPost("{id}/duplicate")] + public async Task> Duplicate(int id) + { + try + { + var duplicate = await _jobService.DuplicateAsync(id); + var loaded = await _jobService.GetByIdAsync(duplicate.Id); + return CreatedAtAction(nameof(GetById), new { id = duplicate.Id }, MapToDetailDto(loaded!)); + } + catch (ArgumentException) + { + return NotFound(); + } + } + + // --- Parts --- + + [HttpGet("{id}/parts")] + public async Task>> GetParts(int id) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + return Ok(job.Parts.Select(MapPartToDto).ToList()); + } + + [HttpPost("{id}/parts")] + public async Task> AddPart(int id, CreateJobPartDto dto) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + decimal lengthInches; + try + { + lengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + + var part = new JobPart + { + JobId = id, + MaterialId = dto.MaterialId, + Name = dto.Name, + LengthInches = lengthInches, + Quantity = dto.Quantity + }; + + await _jobService.AddPartAsync(part); + + // Reload to get material name + var reloadedJob = await _jobService.GetByIdAsync(id); + var addedPart = reloadedJob!.Parts.FirstOrDefault(p => p.Id == part.Id); + return CreatedAtAction(nameof(GetParts), new { id }, MapPartToDto(addedPart ?? part)); + } + + [HttpPut("{id}/parts/{partId}")] + public async Task> UpdatePart(int id, int partId, UpdateJobPartDto dto) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + var part = job.Parts.FirstOrDefault(p => p.Id == partId); + if (part == null) + return NotFound(); + + if (dto.MaterialId.HasValue) part.MaterialId = dto.MaterialId.Value; + if (dto.Name != null) part.Name = dto.Name; + if (dto.Length != null) + { + try + { + part.LengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + } + if (dto.Quantity.HasValue) part.Quantity = dto.Quantity.Value; + + await _jobService.UpdatePartAsync(part); + + var reloadedJob = await _jobService.GetByIdAsync(id); + var updatedPart = reloadedJob!.Parts.FirstOrDefault(p => p.Id == partId); + return Ok(MapPartToDto(updatedPart ?? part)); + } + + [HttpDelete("{id}/parts/{partId}")] + public async Task DeletePart(int id, int partId) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + var part = job.Parts.FirstOrDefault(p => p.Id == partId); + if (part == null) + return NotFound(); + + await _jobService.DeletePartAsync(partId); + return NoContent(); + } + + // --- Stock --- + + [HttpGet("{id}/stock")] + public async Task>> GetStock(int id) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + return Ok(job.Stock.Select(MapStockToDto).ToList()); + } + + [HttpPost("{id}/stock")] + public async Task> AddStock(int id, CreateJobStockDto dto) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + decimal lengthInches; + try + { + lengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + + var stock = new JobStock + { + JobId = id, + MaterialId = dto.MaterialId, + StockItemId = dto.StockItemId, + LengthInches = lengthInches, + Quantity = dto.Quantity, + IsCustomLength = dto.IsCustomLength, + Priority = dto.Priority + }; + + await _jobService.AddStockAsync(stock); + + var reloadedJob = await _jobService.GetByIdAsync(id); + var addedStock = reloadedJob!.Stock.FirstOrDefault(s => s.Id == stock.Id); + return CreatedAtAction(nameof(GetStock), new { id }, MapStockToDto(addedStock ?? stock)); + } + + [HttpPut("{id}/stock/{stockId}")] + public async Task> UpdateStock(int id, int stockId, UpdateJobStockDto dto) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + var stock = job.Stock.FirstOrDefault(s => s.Id == stockId); + if (stock == null) + return NotFound(); + + if (dto.StockItemId.HasValue) stock.StockItemId = dto.StockItemId; + if (dto.Length != null) + { + try + { + stock.LengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + } + if (dto.Quantity.HasValue) stock.Quantity = dto.Quantity.Value; + if (dto.IsCustomLength.HasValue) stock.IsCustomLength = dto.IsCustomLength.Value; + if (dto.Priority.HasValue) stock.Priority = dto.Priority.Value; + + await _jobService.UpdateStockAsync(stock); + + var reloadedJob = await _jobService.GetByIdAsync(id); + var updatedStock = reloadedJob!.Stock.FirstOrDefault(s => s.Id == stockId); + return Ok(MapStockToDto(updatedStock ?? stock)); + } + + [HttpDelete("{id}/stock/{stockId}")] + public async Task DeleteStock(int id, int stockId) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + var stock = job.Stock.FirstOrDefault(s => s.Id == stockId); + if (stock == null) + return NotFound(); + + await _jobService.DeleteStockAsync(stockId); + return NoContent(); + } + + [HttpGet("{id}/available-stock/{materialId}")] + public async Task>> GetAvailableStock(int id, int materialId) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + var items = await _jobService.GetAvailableStockForMaterialAsync(materialId); + return Ok(items.Select(s => new StockItemDto + { + Id = s.Id, + MaterialId = s.MaterialId, + MaterialName = s.Material?.DisplayName ?? string.Empty, + LengthInches = s.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), + Name = s.Name, + QuantityOnHand = s.QuantityOnHand, + IsActive = s.IsActive + }).ToList()); + } + + // --- Packing --- + + [HttpPost("{id}/pack")] + public async Task> Pack(int id, PackJobRequestDto? dto = null) + { + var job = await _jobService.GetByIdAsync(id); + if (job == null) + return NotFound(); + + if (job.Parts.Count == 0) + return BadRequest("Job has no parts to pack"); + + // Determine kerf + decimal kerf = dto?.KerfOverride + ?? job.CuttingTool?.KerfInches + ?? (await _jobService.GetDefaultCuttingToolAsync())?.KerfInches + ?? 0.125m; + + var result = await _packingService.PackAsync(job.Parts, kerf, job.Stock.Any() ? job.Stock : null); + var summary = _packingService.GetSummary(result); + + return Ok(MapPackResult(result, summary)); + } + + // --- Mapping helpers --- + + private static JobDto MapToDto(Job j) => new() + { + Id = j.Id, + JobNumber = j.JobNumber, + Name = j.Name, + Customer = j.Customer, + CuttingToolId = j.CuttingToolId, + CuttingToolName = j.CuttingTool?.Name, + Notes = j.Notes, + CreatedAt = j.CreatedAt, + UpdatedAt = j.UpdatedAt, + PartCount = j.Parts?.Count ?? 0, + StockCount = j.Stock?.Count ?? 0 + }; + + private static JobDetailDto MapToDetailDto(Job j) => new() + { + Id = j.Id, + JobNumber = j.JobNumber, + Name = j.Name, + Customer = j.Customer, + CuttingToolId = j.CuttingToolId, + CuttingToolName = j.CuttingTool?.Name, + Notes = j.Notes, + CreatedAt = j.CreatedAt, + UpdatedAt = j.UpdatedAt, + PartCount = j.Parts?.Count ?? 0, + StockCount = j.Stock?.Count ?? 0, + Parts = j.Parts?.Select(MapPartToDto).ToList() ?? new(), + Stock = j.Stock?.Select(MapStockToDto).ToList() ?? new() + }; + + private static JobPartDto MapPartToDto(JobPart p) => new() + { + Id = p.Id, + JobId = p.JobId, + MaterialId = p.MaterialId, + MaterialName = p.Material?.DisplayName ?? string.Empty, + Name = p.Name, + LengthInches = p.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)p.LengthInches), + Quantity = p.Quantity, + SortOrder = p.SortOrder + }; + + private static JobStockDto MapStockToDto(JobStock s) => new() + { + Id = s.Id, + JobId = s.JobId, + MaterialId = s.MaterialId, + MaterialName = s.Material?.DisplayName ?? string.Empty, + StockItemId = s.StockItemId, + LengthInches = s.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), + Quantity = s.Quantity, + IsCustomLength = s.IsCustomLength, + Priority = s.Priority, + SortOrder = s.SortOrder + }; + + private static PackResponseDto MapPackResult(MultiMaterialPackResult result, MultiMaterialPackingSummary summary) + { + var response = new PackResponseDto(); + + foreach (var mr in result.MaterialResults) + { + var matResult = new MaterialPackResultDto + { + MaterialId = mr.Material.Id, + MaterialName = mr.Material.DisplayName, + InStockBins = mr.InStockBins.Select(MapBinToDto).ToList(), + ToBePurchasedBins = mr.ToBePurchasedBins.Select(MapBinToDto).ToList(), + ItemsNotPlaced = mr.PackResult.ItemsNotUsed.Select(i => new PackedItemDto + { + Name = i.Name, + LengthInches = i.Length, + LengthFormatted = ArchUnits.FormatFromInches(i.Length) + }).ToList() + }; + + var ms = summary.MaterialSummaries.FirstOrDefault(s => s.Material.Id == mr.Material.Id); + if (ms != null) + { + matResult.Summary = new MaterialPackingSummaryDto + { + MaterialId = ms.Material.Id, + MaterialName = ms.Material.DisplayName, + InStockBins = ms.InStockBins, + ToBePurchasedBins = ms.ToBePurchasedBins, + TotalPieces = ms.TotalPieces, + TotalMaterialInches = ms.TotalMaterial, + TotalUsedInches = ms.TotalUsed, + TotalWasteInches = ms.TotalWaste, + Efficiency = ms.Efficiency, + ItemsNotPlaced = ms.ItemsNotPlaced + }; + } + + response.Materials.Add(matResult); + } + + response.Summary = new PackingSummaryDto + { + TotalInStockBins = summary.TotalInStockBins, + TotalToBePurchasedBins = summary.TotalToBePurchasedBins, + TotalPieces = summary.TotalPieces, + TotalMaterialInches = summary.TotalMaterial, + TotalMaterialFormatted = ArchUnits.FormatFromInches(summary.TotalMaterial), + TotalUsedInches = summary.TotalUsed, + TotalUsedFormatted = ArchUnits.FormatFromInches(summary.TotalUsed), + TotalWasteInches = summary.TotalWaste, + TotalWasteFormatted = ArchUnits.FormatFromInches(summary.TotalWaste), + Efficiency = summary.Efficiency, + TotalItemsNotPlaced = summary.TotalItemsNotPlaced, + MaterialSummaries = summary.MaterialSummaries.Select(ms => new MaterialPackingSummaryDto + { + MaterialId = ms.Material.Id, + MaterialName = ms.Material.DisplayName, + InStockBins = ms.InStockBins, + ToBePurchasedBins = ms.ToBePurchasedBins, + TotalPieces = ms.TotalPieces, + TotalMaterialInches = ms.TotalMaterial, + TotalUsedInches = ms.TotalUsed, + TotalWasteInches = ms.TotalWaste, + Efficiency = ms.Efficiency, + ItemsNotPlaced = ms.ItemsNotPlaced + }).ToList() + }; + + return response; + } + + private static PackedBinDto MapBinToDto(CutList.Core.Bin bin) => new() + { + LengthInches = bin.Length, + LengthFormatted = ArchUnits.FormatFromInches(bin.Length), + UsedInches = bin.UsedLength, + UsedFormatted = ArchUnits.FormatFromInches(bin.UsedLength), + WasteInches = bin.RemainingLength, + WasteFormatted = ArchUnits.FormatFromInches(bin.RemainingLength), + Efficiency = bin.Length > 0 ? bin.UsedLength / bin.Length * 100 : 0, + Items = bin.Items.Select(i => new PackedItemDto + { + Name = i.Name, + LengthInches = i.Length, + LengthFormatted = ArchUnits.FormatFromInches(i.Length) + }).ToList() + }; +} diff --git a/CutList.Web/Controllers/MaterialsController.cs b/CutList.Web/Controllers/MaterialsController.cs index eebb367..47f4c41 100644 --- a/CutList.Web/Controllers/MaterialsController.cs +++ b/CutList.Web/Controllers/MaterialsController.cs @@ -1,7 +1,7 @@ -using CutList.Web.Data; using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace CutList.Web.Controllers; @@ -9,48 +9,55 @@ namespace CutList.Web.Controllers; [Route("api/[controller]")] public class MaterialsController : ControllerBase { - private readonly ApplicationDbContext _context; + private readonly MaterialService _materialService; - public MaterialsController(ApplicationDbContext context) + public MaterialsController(MaterialService materialService) { - _context = context; + _materialService = materialService; } [HttpGet] - public async Task>> GetMaterials() + public async Task>> GetMaterials( + [FromQuery] bool includeInactive = false, + [FromQuery] string? shape = null) { - var materials = await _context.Materials - .Where(m => m.IsActive) - .OrderBy(m => m.Shape) - .ThenBy(m => m.SortOrder) - .ThenBy(m => m.Size) - .Select(m => new MaterialDto - { - Id = m.Id, - Shape = m.Shape.GetDisplayName(), - Size = m.Size, - Description = m.Description - }) - .ToListAsync(); + List materials; - return Ok(materials); + if (!string.IsNullOrWhiteSpace(shape)) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (!parsedShape.HasValue) + return BadRequest($"Unknown shape: {shape}"); + + materials = await _materialService.GetByShapeAsync(parsedShape.Value, includeInactive); + } + else + { + materials = await _materialService.GetAllAsync(includeInactive); + } + + return Ok(materials.Select(MapToDto).ToList()); } [HttpGet("{id}")] public async Task> GetMaterial(int id) { - var material = await _context.Materials.FindAsync(id); - - if (material == null || !material.IsActive) + var material = await _materialService.GetByIdAsync(id); + if (material == null) return NotFound(); - return Ok(new MaterialDto - { - Id = material.Id, - Shape = material.Shape.GetDisplayName(), - Size = material.Size, - Description = material.Description - }); + return Ok(MapToDto(material)); + } + + [HttpGet("by-shape/{shape}")] + public async Task>> GetByShape(string shape, [FromQuery] bool includeInactive = false) + { + var parsedShape = MaterialShapeExtensions.ParseShape(shape); + if (!parsedShape.HasValue) + return BadRequest($"Unknown shape: {shape}"); + + var materials = await _materialService.GetByShapeAsync(parsedShape.Value, includeInactive); + return Ok(materials.Select(MapToDto).ToList()); } [HttpPost] @@ -59,38 +66,53 @@ public class MaterialsController : ControllerBase if (string.IsNullOrWhiteSpace(dto.Shape)) return BadRequest("Shape is required"); - if (string.IsNullOrWhiteSpace(dto.Size)) - return BadRequest("Size is required"); - var parsedShape = MaterialShapeExtensions.ParseShape(dto.Shape); if (!parsedShape.HasValue) return BadRequest($"Unknown shape: {dto.Shape}"); - // Check for duplicates - var exists = await _context.Materials - .AnyAsync(m => m.Shape == parsedShape.Value && m.Size == dto.Size && m.IsActive); - - if (exists) - return Conflict($"Material '{dto.Shape} - {dto.Size}' already exists"); + // Parse material type + MaterialType materialType = MaterialType.Steel; + if (!string.IsNullOrWhiteSpace(dto.Type)) + { + if (!Enum.TryParse(dto.Type, true, out materialType)) + return BadRequest($"Unknown material type: {dto.Type}"); + } var material = new Material { Shape = parsedShape.Value, - Size = dto.Size, - Description = dto.Description, - CreatedAt = DateTime.UtcNow + Type = materialType, + Grade = dto.Grade, + Size = dto.Size ?? string.Empty, + Description = dto.Description }; - _context.Materials.Add(material); - await _context.SaveChangesAsync(); - - return CreatedAtAction(nameof(GetMaterial), new { id = material.Id }, new MaterialDto + if (dto.Dimensions != null && dto.Dimensions.Count > 0) { - Id = material.Id, - Shape = material.Shape.GetDisplayName(), - Size = material.Size, - Description = material.Description - }); + var dimensions = MaterialService.CreateDimensionsForShape(parsedShape.Value); + ApplyDimensionValues(dimensions, dto.Dimensions); + + // Check for duplicates using generated size + var generatedSize = dimensions.GenerateSizeString(); + var exists = await _materialService.ExistsAsync(parsedShape.Value, generatedSize); + if (exists) + return Conflict($"Material '{parsedShape.Value.GetDisplayName()} - {generatedSize}' already exists"); + + var created = await _materialService.CreateWithDimensionsAsync(material, dimensions); + return CreatedAtAction(nameof(GetMaterial), new { id = created.Id }, MapToDto(created)); + } + else + { + if (string.IsNullOrWhiteSpace(material.Size)) + return BadRequest("Size is required when dimensions are not provided"); + + var exists = await _materialService.ExistsAsync(parsedShape.Value, material.Size); + if (exists) + return Conflict($"Material '{parsedShape.Value.GetDisplayName()} - {material.Size}' already exists"); + + var created = await _materialService.CreateAsync(material); + return CreatedAtAction(nameof(GetMaterial), new { id = created.Id }, MapToDto(created)); + } } [HttpPost("bulk")] @@ -102,9 +124,9 @@ public class MaterialsController : ControllerBase foreach (var dto in materials) { - if (string.IsNullOrWhiteSpace(dto.Shape) || string.IsNullOrWhiteSpace(dto.Size)) + if (string.IsNullOrWhiteSpace(dto.Shape)) { - errors.Add($"Invalid material: Shape and Size are required"); + errors.Add("Invalid material: Shape is required"); continue; } @@ -115,27 +137,61 @@ public class MaterialsController : ControllerBase continue; } - var exists = await _context.Materials - .AnyAsync(m => m.Shape == parsedShape.Value && m.Size == dto.Size && m.IsActive); + var size = dto.Size ?? string.Empty; + if (dto.Dimensions != null && dto.Dimensions.Count > 0) + { + var dimensions = MaterialService.CreateDimensionsForShape(parsedShape.Value); + ApplyDimensionValues(dimensions, dto.Dimensions); + size = dimensions.GenerateSizeString(); + } + + if (string.IsNullOrWhiteSpace(size)) + { + errors.Add($"Size is required for {dto.Shape}"); + continue; + } + + var exists = await _materialService.ExistsAsync(parsedShape.Value, size); if (exists) { skipped++; continue; } - _context.Materials.Add(new Material + MaterialType materialType = MaterialType.Steel; + if (!string.IsNullOrWhiteSpace(dto.Type)) + { + if (!Enum.TryParse(dto.Type, true, out materialType)) + { + errors.Add($"Unknown material type: {dto.Type}"); + continue; + } + } + + var material = new Material { Shape = parsedShape.Value, - Size = dto.Size, - Description = dto.Description, - CreatedAt = DateTime.UtcNow - }); + Type = materialType, + Grade = dto.Grade, + Size = size, + Description = dto.Description + }; + + if (dto.Dimensions != null && dto.Dimensions.Count > 0) + { + var dimensions = MaterialService.CreateDimensionsForShape(parsedShape.Value); + ApplyDimensionValues(dimensions, dto.Dimensions); + await _materialService.CreateWithDimensionsAsync(material, dimensions); + } + else + { + await _materialService.CreateAsync(material); + } + created++; } - await _context.SaveChangesAsync(); - return Ok(new BulkCreateResult { Created = created, @@ -144,39 +200,192 @@ public class MaterialsController : ControllerBase }); } - [HttpDelete("{id}")] - public async Task DeleteMaterial(int id) + [HttpPut("{id}")] + public async Task> UpdateMaterial(int id, UpdateMaterialDto dto) { - var material = await _context.Materials.FindAsync(id); - + var material = await _materialService.GetByIdAsync(id); if (material == null) return NotFound(); - material.IsActive = false; - await _context.SaveChangesAsync(); + if (dto.Type != null) + { + if (!Enum.TryParse(dto.Type, true, out var materialType)) + return BadRequest($"Unknown material type: {dto.Type}"); + material.Type = materialType; + } + if (dto.Grade != null) material.Grade = dto.Grade; + if (dto.Size != null) material.Size = dto.Size; + if (dto.Description != null) material.Description = dto.Description; + + if (dto.Dimensions != null && dto.Dimensions.Count > 0) + { + var dimensions = material.Dimensions ?? MaterialService.CreateDimensionsForShape(material.Shape); + ApplyDimensionValues(dimensions, dto.Dimensions); + await _materialService.UpdateWithDimensionsAsync(material, dimensions, dto.RegenerateSize ?? false); + } + else + { + await _materialService.UpdateAsync(material); + } + + var updated = await _materialService.GetByIdAsync(id); + return Ok(MapToDto(updated!)); + } + + [HttpDelete("{id}")] + public async Task DeleteMaterial(int id) + { + var material = await _materialService.GetByIdAsync(id); + if (material == null) + return NotFound(); + + await _materialService.DeleteAsync(id); return NoContent(); } -} -public class MaterialDto -{ - public int Id { get; set; } - public string Shape { get; set; } = string.Empty; - public string Size { get; set; } = string.Empty; - public string? Description { get; set; } -} + [HttpPost("search")] + public async Task>> SearchMaterials(MaterialSearchDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Shape)) + return BadRequest("Shape is required"); -public class CreateMaterialDto -{ - public string Shape { get; set; } = string.Empty; - public string Size { get; set; } = string.Empty; - public string? Description { get; set; } -} + var parsedShape = MaterialShapeExtensions.ParseShape(dto.Shape); + if (!parsedShape.HasValue) + return BadRequest($"Unknown shape: {dto.Shape}"); -public class BulkCreateResult -{ - public int Created { get; set; } - public int Skipped { get; set; } - public List Errors { get; set; } = new(); + var results = parsedShape.Value switch + { + MaterialShape.RoundBar => await _materialService.SearchRoundBarByDiameterAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.RoundTube => await _materialService.SearchRoundTubeByODAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.FlatBar => await _materialService.SearchFlatBarByWidthAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.SquareBar => await _materialService.SearchSquareBarBySizeAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.SquareTube => await _materialService.SearchSquareTubeBySizeAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.RectangularTube => await _materialService.SearchRectangularTubeByWidthAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.Angle => await _materialService.SearchAngleByLegAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.Channel => await _materialService.SearchChannelByHeightAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.IBeam => await _materialService.SearchIBeamByHeightAsync(dto.TargetValue, dto.Tolerance), + MaterialShape.Pipe => await _materialService.SearchPipeByNominalSizeAsync(dto.TargetValue, dto.Tolerance), + _ => new List() + }; + + return Ok(results.Select(MapToDto).ToList()); + } + + private static MaterialDto MapToDto(Material m) => new() + { + Id = m.Id, + Shape = m.Shape.GetDisplayName(), + Type = m.Type.ToString(), + Grade = m.Grade, + Size = m.Size, + Description = m.Description, + IsActive = m.IsActive, + Dimensions = m.Dimensions != null ? MapDimensionsToDto(m.Dimensions) : null + }; + + private static MaterialDimensionsDto MapDimensionsToDto(MaterialDimensions d) + { + var dto = new MaterialDimensionsDto + { + DimensionType = d.GetType().Name.Replace("Dimensions", "") + }; + + // Extract dimension values based on type + switch (d) + { + case RoundBarDimensions rb: + dto.Values["Diameter"] = rb.Diameter; + break; + case RoundTubeDimensions rt: + dto.Values["OuterDiameter"] = rt.OuterDiameter; + dto.Values["Wall"] = rt.Wall; + break; + case FlatBarDimensions fb: + dto.Values["Width"] = fb.Width; + dto.Values["Thickness"] = fb.Thickness; + break; + case SquareBarDimensions sb: + dto.Values["Size"] = sb.Size; + break; + case SquareTubeDimensions st: + dto.Values["Size"] = st.Size; + dto.Values["Wall"] = st.Wall; + break; + case RectangularTubeDimensions rect: + dto.Values["Width"] = rect.Width; + dto.Values["Height"] = rect.Height; + dto.Values["Wall"] = rect.Wall; + break; + case AngleDimensions a: + dto.Values["Leg1"] = a.Leg1; + dto.Values["Leg2"] = a.Leg2; + dto.Values["Thickness"] = a.Thickness; + break; + case ChannelDimensions c: + dto.Values["Height"] = c.Height; + dto.Values["Flange"] = c.Flange; + dto.Values["Web"] = c.Web; + break; + case IBeamDimensions ib: + dto.Values["Height"] = ib.Height; + dto.Values["WeightPerFoot"] = ib.WeightPerFoot; + break; + case PipeDimensions p: + dto.Values["NominalSize"] = p.NominalSize; + if (p.Wall.HasValue) dto.Values["Wall"] = p.Wall.Value; + break; + } + + return dto; + } + + private static void ApplyDimensionValues(MaterialDimensions dimensions, Dictionary values) + { + switch (dimensions) + { + case RoundBarDimensions rb: + if (values.TryGetValue("Diameter", out var diameter)) rb.Diameter = diameter; + break; + case RoundTubeDimensions rt: + if (values.TryGetValue("OuterDiameter", out var od)) rt.OuterDiameter = od; + if (values.TryGetValue("Wall", out var rtWall)) rt.Wall = rtWall; + break; + case FlatBarDimensions fb: + if (values.TryGetValue("Width", out var fbWidth)) fb.Width = fbWidth; + if (values.TryGetValue("Thickness", out var fbThick)) fb.Thickness = fbThick; + break; + case SquareBarDimensions sb: + if (values.TryGetValue("Size", out var sbSize)) sb.Size = sbSize; + break; + case SquareTubeDimensions st: + if (values.TryGetValue("Size", out var stSize)) st.Size = stSize; + if (values.TryGetValue("Wall", out var stWall)) st.Wall = stWall; + break; + case RectangularTubeDimensions rect: + if (values.TryGetValue("Width", out var rectWidth)) rect.Width = rectWidth; + if (values.TryGetValue("Height", out var rectHeight)) rect.Height = rectHeight; + if (values.TryGetValue("Wall", out var rectWall)) rect.Wall = rectWall; + break; + case AngleDimensions a: + if (values.TryGetValue("Leg1", out var leg1)) a.Leg1 = leg1; + if (values.TryGetValue("Leg2", out var leg2)) a.Leg2 = leg2; + if (values.TryGetValue("Thickness", out var aThick)) a.Thickness = aThick; + break; + case ChannelDimensions c: + if (values.TryGetValue("Height", out var cHeight)) c.Height = cHeight; + if (values.TryGetValue("Flange", out var flange)) c.Flange = flange; + if (values.TryGetValue("Web", out var web)) c.Web = web; + break; + case IBeamDimensions ib: + if (values.TryGetValue("Height", out var ibHeight)) ib.Height = ibHeight; + if (values.TryGetValue("WeightPerFoot", out var weight)) ib.WeightPerFoot = weight; + break; + case PipeDimensions p: + if (values.TryGetValue("NominalSize", out var nps)) p.NominalSize = nps; + if (values.TryGetValue("Wall", out var pWall)) p.Wall = pWall; + if (values.TryGetValue("Schedule", out var schedule)) p.Schedule = schedule.ToString(); + break; + } + } } diff --git a/CutList.Web/Controllers/PackingController.cs b/CutList.Web/Controllers/PackingController.cs new file mode 100644 index 0000000..b989cf3 --- /dev/null +++ b/CutList.Web/Controllers/PackingController.cs @@ -0,0 +1,156 @@ +using CutList.Core; +using CutList.Core.Formatting; +using CutList.Core.Nesting; +using CutList.Web.DTOs; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class PackingController : ControllerBase +{ + [HttpPost("optimize")] + public ActionResult Optimize(StandalonePackRequestDto dto) + { + if (dto.Parts.Count == 0) + return BadRequest("At least one part is required"); + + if (dto.StockBins.Count == 0) + return BadRequest("At least one stock bin is required"); + + // Parse parts + var items = new List(); + foreach (var part in dto.Parts) + { + double length; + try + { + length = ArchUnits.ParseToInches(part.Length); + } + catch + { + return BadRequest($"Invalid length format for part '{part.Name}': {part.Length}"); + } + + for (int i = 0; i < part.Quantity; i++) + { + items.Add(new BinItem(part.Name, length)); + } + } + + // Parse stock bins + var multiBins = new List(); + foreach (var bin in dto.StockBins) + { + double length; + try + { + length = ArchUnits.ParseToInches(bin.Length); + } + catch + { + return BadRequest($"Invalid length format for stock bin: {bin.Length}"); + } + + multiBins.Add(new MultiBin(length, bin.Quantity, bin.Priority)); + } + + // Select strategy + var strategy = dto.Strategy?.ToLowerInvariant() switch + { + "bestfit" => PackingStrategy.BestFit, + "exhaustive" => PackingStrategy.Exhaustive, + _ => PackingStrategy.AdvancedFit + }; + + // Run packing + var engine = new MultiBinEngine + { + Spacing = (double)dto.Kerf, + Strategy = strategy + }; + + engine.SetBins(multiBins); + var result = engine.Pack(items); + + // Map result + var bins = result.Bins.Select(bin => new PackedBinDto + { + LengthInches = bin.Length, + LengthFormatted = ArchUnits.FormatFromInches(bin.Length), + UsedInches = bin.UsedLength, + UsedFormatted = ArchUnits.FormatFromInches(bin.UsedLength), + WasteInches = bin.RemainingLength, + WasteFormatted = ArchUnits.FormatFromInches(bin.RemainingLength), + Efficiency = bin.Length > 0 ? bin.UsedLength / bin.Length * 100 : 0, + Items = bin.Items.Select(i => new PackedItemDto + { + Name = i.Name, + LengthInches = i.Length, + LengthFormatted = ArchUnits.FormatFromInches(i.Length) + }).ToList() + }).ToList(); + + var itemsNotPlaced = result.ItemsNotUsed.Select(i => new PackedItemDto + { + Name = i.Name, + LengthInches = i.Length, + LengthFormatted = ArchUnits.FormatFromInches(i.Length) + }).ToList(); + + var totalMaterial = result.Bins.Sum(b => b.Length); + var totalUsed = result.Bins.Sum(b => b.UsedLength); + var totalWaste = result.Bins.Sum(b => b.RemainingLength); + + return Ok(new + { + Bins = bins, + ItemsNotPlaced = itemsNotPlaced, + Summary = new + { + TotalBins = result.Bins.Count, + TotalPieces = result.Bins.Sum(b => b.Items.Count), + TotalMaterialInches = totalMaterial, + TotalMaterialFormatted = ArchUnits.FormatFromInches(totalMaterial), + TotalUsedInches = totalUsed, + TotalUsedFormatted = ArchUnits.FormatFromInches(totalUsed), + TotalWasteInches = totalWaste, + TotalWasteFormatted = ArchUnits.FormatFromInches(totalWaste), + Efficiency = totalMaterial > 0 ? totalUsed / totalMaterial * 100 : 0, + ItemsNotPlaced = result.ItemsNotUsed.Count + } + }); + } + + [HttpPost("parse-length")] + public ActionResult ParseLength(ParseLengthRequestDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Input)) + return BadRequest("Input is required"); + + try + { + var inches = ArchUnits.ParseToInches(dto.Input); + return Ok(new ParseLengthResponseDto + { + Inches = inches, + Formatted = ArchUnits.FormatFromInches(inches) + }); + } + catch (Exception ex) + { + return BadRequest($"Could not parse '{dto.Input}': {ex.Message}"); + } + } + + [HttpPost("format-length")] + public ActionResult FormatLength(FormatLengthRequestDto dto) + { + return Ok(new FormatLengthResponseDto + { + Inches = dto.Inches, + Formatted = ArchUnits.FormatFromInches(dto.Inches) + }); + } +} diff --git a/CutList.Web/Controllers/StockItemsController.cs b/CutList.Web/Controllers/StockItemsController.cs new file mode 100644 index 0000000..0f79dc3 --- /dev/null +++ b/CutList.Web/Controllers/StockItemsController.cs @@ -0,0 +1,272 @@ +using CutList.Core.Formatting; +using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/stock-items")] +public class StockItemsController : ControllerBase +{ + private readonly StockItemService _stockItemService; + private readonly SupplierService _supplierService; + + public StockItemsController(StockItemService stockItemService, SupplierService supplierService) + { + _stockItemService = stockItemService; + _supplierService = supplierService; + } + + [HttpGet] + public async Task>> GetAll( + [FromQuery] bool includeInactive = false, + [FromQuery] int? materialId = null) + { + List items; + if (materialId.HasValue) + items = await _stockItemService.GetByMaterialAsync(materialId.Value, includeInactive); + else + items = await _stockItemService.GetAllAsync(includeInactive); + + return Ok(items.Select(MapToDto).ToList()); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + return Ok(MapToDto(item)); + } + + [HttpPost] + public async Task> Create(CreateStockItemDto dto) + { + double lengthInches; + try + { + lengthInches = ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + + var exists = await _stockItemService.ExistsAsync(dto.MaterialId, (decimal)lengthInches); + if (exists) + return Conflict("A stock item with this material and length already exists"); + + var stockItem = new StockItem + { + MaterialId = dto.MaterialId, + LengthInches = (decimal)lengthInches, + Name = dto.Name, + QuantityOnHand = dto.QuantityOnHand, + Notes = dto.Notes + }; + + await _stockItemService.CreateAsync(stockItem); + + // Reload with includes + var created = await _stockItemService.GetByIdAsync(stockItem.Id); + return CreatedAtAction(nameof(GetById), new { id = stockItem.Id }, MapToDto(created!)); + } + + [HttpPut("{id}")] + public async Task> Update(int id, UpdateStockItemDto dto) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + if (dto.Length != null) + { + try + { + item.LengthInches = (decimal)ArchUnits.ParseToInches(dto.Length); + } + catch + { + return BadRequest($"Invalid length format: {dto.Length}"); + } + } + + if (dto.Name != null) item.Name = dto.Name; + if (dto.Notes != null) item.Notes = dto.Notes; + + await _stockItemService.UpdateAsync(item); + return Ok(MapToDto(item)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + await _stockItemService.DeleteAsync(id); + return NoContent(); + } + + [HttpGet("by-material/{materialId}")] + public async Task>> GetByMaterial(int materialId) + { + var items = await _stockItemService.GetByMaterialAsync(materialId); + return Ok(items.Select(MapToDto).ToList()); + } + + [HttpGet("{id}/offerings")] + public async Task>> GetOfferings(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + var offerings = await _supplierService.GetOfferingsForStockItemAsync(id); + return Ok(offerings.Select(MapOfferingToDto).ToList()); + } + + [HttpGet("{id}/pricing")] + public async Task> GetPricing(int id) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + var avgCost = await _stockItemService.GetAverageCostAsync(id); + var lastPrice = await _stockItemService.GetLastPurchasePriceAsync(id); + + return Ok(new StockPricingDto + { + AverageCost = avgCost, + LastPurchasePrice = lastPrice + }); + } + + [HttpGet("{id}/transactions")] + public async Task>> GetTransactions(int id, [FromQuery] int? limit = null) + { + var item = await _stockItemService.GetByIdAsync(id); + if (item == null) + return NotFound(); + + var transactions = await _stockItemService.GetTransactionHistoryAsync(id, limit); + return Ok(transactions.Select(MapTransactionToDto).ToList()); + } + + [HttpPost("{id}/receive")] + public async Task> ReceiveStock(int id, AddStockDto dto) + { + try + { + var transaction = await _stockItemService.AddStockAsync(id, dto.Quantity, dto.SupplierId, dto.UnitPrice, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/use")] + public async Task> UseStock(int id, UseStockDto dto) + { + try + { + var transaction = await _stockItemService.UseStockAsync(id, dto.Quantity, dto.JobId, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/adjust")] + public async Task> AdjustStock(int id, AdjustStockDto dto) + { + try + { + var transaction = await _stockItemService.AdjustStockAsync(id, dto.NewQuantity, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/scrap")] + public async Task> ScrapStock(int id, ScrapStockDto dto) + { + try + { + var transaction = await _stockItemService.ScrapStockAsync(id, dto.Quantity, dto.Notes); + return Ok(MapTransactionToDto(transaction)); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/recalculate")] + public async Task> RecalculateStock(int id) + { + try + { + var newQuantity = await _stockItemService.RecalculateQuantityAsync(id); + return Ok(new { QuantityOnHand = newQuantity }); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + private static StockItemDto MapToDto(StockItem s) => new() + { + Id = s.Id, + MaterialId = s.MaterialId, + MaterialName = s.Material?.DisplayName ?? string.Empty, + LengthInches = s.LengthInches, + LengthFormatted = ArchUnits.FormatFromInches((double)s.LengthInches), + Name = s.Name, + QuantityOnHand = s.QuantityOnHand, + Notes = s.Notes, + IsActive = s.IsActive + }; + + private static StockTransactionDto MapTransactionToDto(StockTransaction t) => new() + { + Id = t.Id, + StockItemId = t.StockItemId, + Quantity = t.Quantity, + Type = t.Type.ToString(), + JobId = t.JobId, + JobNumber = t.Job?.JobNumber, + SupplierId = t.SupplierId, + SupplierName = t.Supplier?.Name, + UnitPrice = t.UnitPrice, + Notes = t.Notes, + CreatedAt = t.CreatedAt + }; + + private static OfferingDto MapOfferingToDto(SupplierOffering o) => new() + { + Id = o.Id, + SupplierId = o.SupplierId, + SupplierName = o.Supplier?.Name, + StockItemId = o.StockItemId, + PartNumber = o.PartNumber, + SupplierDescription = o.SupplierDescription, + Price = o.Price, + Notes = o.Notes, + IsActive = o.IsActive + }; +} diff --git a/CutList.Web/Controllers/SuppliersController.cs b/CutList.Web/Controllers/SuppliersController.cs new file mode 100644 index 0000000..e13ac0e --- /dev/null +++ b/CutList.Web/Controllers/SuppliersController.cs @@ -0,0 +1,172 @@ +using CutList.Core.Formatting; +using CutList.Web.Data.Entities; +using CutList.Web.DTOs; +using CutList.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CutList.Web.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SuppliersController : ControllerBase +{ + private readonly SupplierService _supplierService; + + public SuppliersController(SupplierService supplierService) + { + _supplierService = supplierService; + } + + [HttpGet] + public async Task>> GetAll([FromQuery] bool includeInactive = false) + { + var suppliers = await _supplierService.GetAllAsync(includeInactive); + return Ok(suppliers.Select(MapToDto).ToList()); + } + + [HttpGet("{id}")] + public async Task> GetById(int id) + { + var supplier = await _supplierService.GetByIdAsync(id); + if (supplier == null) + return NotFound(); + + return Ok(MapToDto(supplier)); + } + + [HttpPost] + public async Task> Create(CreateSupplierDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name)) + return BadRequest("Name is required"); + + var supplier = new Supplier + { + Name = dto.Name, + ContactInfo = dto.ContactInfo, + Notes = dto.Notes + }; + + await _supplierService.CreateAsync(supplier); + return CreatedAtAction(nameof(GetById), new { id = supplier.Id }, MapToDto(supplier)); + } + + [HttpPut("{id}")] + public async Task> Update(int id, UpdateSupplierDto dto) + { + var supplier = await _supplierService.GetByIdAsync(id); + if (supplier == null) + return NotFound(); + + if (dto.Name != null) supplier.Name = dto.Name; + if (dto.ContactInfo != null) supplier.ContactInfo = dto.ContactInfo; + if (dto.Notes != null) supplier.Notes = dto.Notes; + + await _supplierService.UpdateAsync(supplier); + return Ok(MapToDto(supplier)); + } + + [HttpDelete("{id}")] + public async Task Delete(int id) + { + var supplier = await _supplierService.GetByIdAsync(id); + if (supplier == null) + return NotFound(); + + await _supplierService.DeleteAsync(id); + return NoContent(); + } + + // --- Offerings --- + + [HttpGet("{id}/offerings")] + public async Task>> GetOfferings(int id) + { + var supplier = await _supplierService.GetByIdAsync(id); + if (supplier == null) + return NotFound(); + + var offerings = await _supplierService.GetOfferingsForSupplierAsync(id); + return Ok(offerings.Select(MapOfferingToDto).ToList()); + } + + [HttpPost("{id}/offerings")] + public async Task> CreateOffering(int id, CreateOfferingDto dto) + { + var supplier = await _supplierService.GetByIdAsync(id); + if (supplier == null) + return NotFound(); + + var exists = await _supplierService.OfferingExistsAsync(id, dto.StockItemId); + if (exists) + return Conflict("An offering for this supplier and stock item already exists"); + + var offering = new SupplierOffering + { + SupplierId = id, + StockItemId = dto.StockItemId, + PartNumber = dto.PartNumber, + SupplierDescription = dto.SupplierDescription, + Price = dto.Price, + Notes = dto.Notes + }; + + await _supplierService.AddOfferingAsync(offering); + + // Reload with includes + var created = await _supplierService.GetOfferingByIdAsync(offering.Id); + return CreatedAtAction(nameof(GetOfferings), new { id }, MapOfferingToDto(created!)); + } + + [HttpPut("{supplierId}/offerings/{offeringId}")] + public async Task> UpdateOffering(int supplierId, int offeringId, UpdateOfferingDto dto) + { + var offering = await _supplierService.GetOfferingByIdAsync(offeringId); + if (offering == null || offering.SupplierId != supplierId) + return NotFound(); + + if (dto.PartNumber != null) offering.PartNumber = dto.PartNumber; + if (dto.SupplierDescription != null) offering.SupplierDescription = dto.SupplierDescription; + if (dto.Price.HasValue) offering.Price = dto.Price; + if (dto.Notes != null) offering.Notes = dto.Notes; + + await _supplierService.UpdateOfferingAsync(offering); + return Ok(MapOfferingToDto(offering)); + } + + [HttpDelete("{supplierId}/offerings/{offeringId}")] + public async Task DeleteOffering(int supplierId, int offeringId) + { + var offering = await _supplierService.GetOfferingByIdAsync(offeringId); + if (offering == null || offering.SupplierId != supplierId) + return NotFound(); + + await _supplierService.DeleteOfferingAsync(offeringId); + return NoContent(); + } + + private static SupplierDto MapToDto(Supplier s) => new() + { + Id = s.Id, + Name = s.Name, + ContactInfo = s.ContactInfo, + Notes = s.Notes, + IsActive = s.IsActive + }; + + private static OfferingDto MapOfferingToDto(SupplierOffering o) => new() + { + Id = o.Id, + SupplierId = o.SupplierId, + SupplierName = o.Supplier?.Name, + StockItemId = o.StockItemId, + MaterialName = o.StockItem?.Material?.DisplayName, + LengthInches = o.StockItem?.LengthInches, + LengthFormatted = o.StockItem != null ? ArchUnits.FormatFromInches((double)o.StockItem.LengthInches) : null, + PartNumber = o.PartNumber, + SupplierDescription = o.SupplierDescription, + Price = o.Price, + Notes = o.Notes, + IsActive = o.IsActive + }; +} diff --git a/CutList.Web/DTOs/CuttingToolDtos.cs b/CutList.Web/DTOs/CuttingToolDtos.cs new file mode 100644 index 0000000..bee640f --- /dev/null +++ b/CutList.Web/DTOs/CuttingToolDtos.cs @@ -0,0 +1,24 @@ +namespace CutList.Web.DTOs; + +public class CuttingToolDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public decimal KerfInches { get; set; } + public bool IsDefault { get; set; } + public bool IsActive { get; set; } +} + +public class CreateCuttingToolDto +{ + public string Name { get; set; } = string.Empty; + public decimal KerfInches { get; set; } + public bool IsDefault { get; set; } +} + +public class UpdateCuttingToolDto +{ + public string? Name { get; set; } + public decimal? KerfInches { get; set; } + public bool? IsDefault { get; set; } +} diff --git a/CutList.Web/DTOs/JobDtos.cs b/CutList.Web/DTOs/JobDtos.cs new file mode 100644 index 0000000..ab37a3b --- /dev/null +++ b/CutList.Web/DTOs/JobDtos.cs @@ -0,0 +1,111 @@ +namespace CutList.Web.DTOs; + +public class JobDto +{ + public int Id { get; set; } + public string JobNumber { get; set; } = string.Empty; + public string? Name { get; set; } + public string? Customer { get; set; } + public int? CuttingToolId { get; set; } + public string? CuttingToolName { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public int PartCount { get; set; } + public int StockCount { get; set; } +} + +public class JobDetailDto : JobDto +{ + public List Parts { get; set; } = new(); + public List Stock { get; set; } = new(); +} + +public class CreateJobDto +{ + public string? Name { get; set; } + public string? Customer { get; set; } + public int? CuttingToolId { get; set; } + public string? Notes { get; set; } +} + +public class UpdateJobDto +{ + public string? Name { get; set; } + public string? Customer { get; set; } + public int? CuttingToolId { get; set; } + public string? Notes { get; set; } +} + +public class JobPartDto +{ + public int Id { get; set; } + public int JobId { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public decimal LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public int Quantity { get; set; } + public int SortOrder { get; set; } +} + +public class CreateJobPartDto +{ + public int MaterialId { get; set; } + public string Name { get; set; } = string.Empty; + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = 1; +} + +public class UpdateJobPartDto +{ + public int? MaterialId { get; set; } + public string? Name { get; set; } + public string? Length { get; set; } + public int? Quantity { get; set; } +} + +public class JobStockDto +{ + public int Id { get; set; } + public int JobId { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public int? StockItemId { get; set; } + public decimal LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public int Quantity { get; set; } + public bool IsCustomLength { get; set; } + public int Priority { get; set; } + public int SortOrder { get; set; } +} + +public class CreateJobStockDto +{ + public int MaterialId { get; set; } + public int? StockItemId { get; set; } + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = 1; + public bool IsCustomLength { get; set; } + public int Priority { get; set; } = 10; +} + +public class UpdateJobStockDto +{ + public int? StockItemId { get; set; } + public string? Length { get; set; } + public int? Quantity { get; set; } + public bool? IsCustomLength { get; set; } + public int? Priority { get; set; } +} + +public class QuickCreateJobDto +{ + public string? Customer { get; set; } +} + +public class PackJobRequestDto +{ + public decimal? KerfOverride { get; set; } +} diff --git a/CutList.Web/DTOs/MaterialDtos.cs b/CutList.Web/DTOs/MaterialDtos.cs new file mode 100644 index 0000000..bbe5b17 --- /dev/null +++ b/CutList.Web/DTOs/MaterialDtos.cs @@ -0,0 +1,53 @@ +namespace CutList.Web.DTOs; + +public class MaterialDto +{ + public int Id { get; set; } + public string Shape { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string? Grade { get; set; } + public string Size { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsActive { get; set; } + public MaterialDimensionsDto? Dimensions { get; set; } +} + +public class CreateMaterialDto +{ + public string Shape { get; set; } = string.Empty; + public string? Type { get; set; } + public string? Grade { get; set; } + public string? Size { get; set; } + public string? Description { get; set; } + public Dictionary? Dimensions { get; set; } +} + +public class UpdateMaterialDto +{ + public string? Type { get; set; } + public string? Grade { get; set; } + public string? Size { get; set; } + public string? Description { get; set; } + public bool? RegenerateSize { get; set; } + public Dictionary? Dimensions { get; set; } +} + +public class BulkCreateResult +{ + public int Created { get; set; } + public int Skipped { get; set; } + public List Errors { get; set; } = new(); +} + +public class MaterialDimensionsDto +{ + public string DimensionType { get; set; } = string.Empty; + public Dictionary Values { get; set; } = new(); +} + +public class MaterialSearchDto +{ + public string Shape { get; set; } = string.Empty; + public decimal TargetValue { get; set; } + public decimal Tolerance { get; set; } = 0.1m; +} diff --git a/CutList.Web/DTOs/PackResultDtos.cs b/CutList.Web/DTOs/PackResultDtos.cs new file mode 100644 index 0000000..2398119 --- /dev/null +++ b/CutList.Web/DTOs/PackResultDtos.cs @@ -0,0 +1,66 @@ +namespace CutList.Web.DTOs; + +public class PackResponseDto +{ + public List Materials { get; set; } = new(); + public PackingSummaryDto Summary { get; set; } = new(); +} + +public class MaterialPackResultDto +{ + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public List InStockBins { get; set; } = new(); + public List ToBePurchasedBins { get; set; } = new(); + public List ItemsNotPlaced { get; set; } = new(); + public MaterialPackingSummaryDto Summary { get; set; } = new(); +} + +public class PackedBinDto +{ + public double LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public double UsedInches { get; set; } + public string UsedFormatted { get; set; } = string.Empty; + public double WasteInches { get; set; } + public string WasteFormatted { get; set; } = string.Empty; + public double Efficiency { get; set; } + public List Items { get; set; } = new(); +} + +public class PackedItemDto +{ + public string Name { get; set; } = string.Empty; + public double LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; +} + +public class PackingSummaryDto +{ + public int TotalInStockBins { get; set; } + public int TotalToBePurchasedBins { get; set; } + public int TotalPieces { get; set; } + public double TotalMaterialInches { get; set; } + public string TotalMaterialFormatted { get; set; } = string.Empty; + public double TotalUsedInches { get; set; } + public string TotalUsedFormatted { get; set; } = string.Empty; + public double TotalWasteInches { get; set; } + public string TotalWasteFormatted { get; set; } = string.Empty; + public double Efficiency { get; set; } + public int TotalItemsNotPlaced { get; set; } + public List MaterialSummaries { get; set; } = new(); +} + +public class MaterialPackingSummaryDto +{ + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public int InStockBins { get; set; } + public int ToBePurchasedBins { get; set; } + public int TotalPieces { get; set; } + public double TotalMaterialInches { get; set; } + public double TotalUsedInches { get; set; } + public double TotalWasteInches { get; set; } + public double Efficiency { get; set; } + public int ItemsNotPlaced { get; set; } +} diff --git a/CutList.Web/DTOs/PackingDtos.cs b/CutList.Web/DTOs/PackingDtos.cs new file mode 100644 index 0000000..43dc6fa --- /dev/null +++ b/CutList.Web/DTOs/PackingDtos.cs @@ -0,0 +1,45 @@ +namespace CutList.Web.DTOs; + +public class StandalonePackRequestDto +{ + public List Parts { get; set; } = new(); + public List StockBins { get; set; } = new(); + public decimal Kerf { get; set; } = 0.125m; + public string Strategy { get; set; } = "advanced"; +} + +public class PartInputDto +{ + public string Name { get; set; } = string.Empty; + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = 1; +} + +public class StockBinInputDto +{ + public string Length { get; set; } = string.Empty; + public int Quantity { get; set; } = -1; + public int Priority { get; set; } = 25; +} + +public class ParseLengthRequestDto +{ + public string Input { get; set; } = string.Empty; +} + +public class ParseLengthResponseDto +{ + public double Inches { get; set; } + public string Formatted { get; set; } = string.Empty; +} + +public class FormatLengthRequestDto +{ + public double Inches { get; set; } +} + +public class FormatLengthResponseDto +{ + public string Formatted { get; set; } = string.Empty; + public double Inches { get; set; } +} diff --git a/CutList.Web/DTOs/StockItemDtos.cs b/CutList.Web/DTOs/StockItemDtos.cs new file mode 100644 index 0000000..c46e585 --- /dev/null +++ b/CutList.Web/DTOs/StockItemDtos.cs @@ -0,0 +1,78 @@ +namespace CutList.Web.DTOs; + +public class StockItemDto +{ + public int Id { get; set; } + public int MaterialId { get; set; } + public string MaterialName { get; set; } = string.Empty; + public decimal LengthInches { get; set; } + public string LengthFormatted { get; set; } = string.Empty; + public string? Name { get; set; } + public int QuantityOnHand { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class CreateStockItemDto +{ + public int MaterialId { get; set; } + public string Length { get; set; } = string.Empty; + public string? Name { get; set; } + public int QuantityOnHand { get; set; } + public string? Notes { get; set; } +} + +public class UpdateStockItemDto +{ + public string? Length { get; set; } + public string? Name { get; set; } + public string? Notes { get; set; } +} + +public class StockTransactionDto +{ + public int Id { get; set; } + public int StockItemId { get; set; } + public int Quantity { get; set; } + public string Type { get; set; } = string.Empty; + public int? JobId { get; set; } + public string? JobNumber { get; set; } + public int? SupplierId { get; set; } + public string? SupplierName { get; set; } + public decimal? UnitPrice { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class AddStockDto +{ + public int Quantity { get; set; } + public int? SupplierId { get; set; } + public decimal? UnitPrice { get; set; } + public string? Notes { get; set; } +} + +public class UseStockDto +{ + public int Quantity { get; set; } + public int? JobId { get; set; } + public string? Notes { get; set; } +} + +public class AdjustStockDto +{ + public int NewQuantity { get; set; } + public string? Notes { get; set; } +} + +public class ScrapStockDto +{ + public int Quantity { get; set; } + public string? Notes { get; set; } +} + +public class StockPricingDto +{ + public decimal? AverageCost { get; set; } + public decimal? LastPurchasePrice { get; set; } +} diff --git a/CutList.Web/DTOs/SupplierDtos.cs b/CutList.Web/DTOs/SupplierDtos.cs new file mode 100644 index 0000000..9c70813 --- /dev/null +++ b/CutList.Web/DTOs/SupplierDtos.cs @@ -0,0 +1,57 @@ +namespace CutList.Web.DTOs; + +public class SupplierDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? ContactInfo { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class CreateSupplierDto +{ + public string Name { get; set; } = string.Empty; + public string? ContactInfo { get; set; } + public string? Notes { get; set; } +} + +public class UpdateSupplierDto +{ + public string? Name { get; set; } + public string? ContactInfo { get; set; } + public string? Notes { get; set; } +} + +public class OfferingDto +{ + public int Id { get; set; } + public int SupplierId { get; set; } + public string? SupplierName { get; set; } + public int StockItemId { get; set; } + public string? MaterialName { get; set; } + public decimal? LengthInches { get; set; } + public string? LengthFormatted { get; set; } + public string? PartNumber { get; set; } + public string? SupplierDescription { get; set; } + public decimal? Price { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } +} + +public class CreateOfferingDto +{ + public int StockItemId { get; set; } + public string? PartNumber { get; set; } + public string? SupplierDescription { get; set; } + public decimal? Price { get; set; } + public string? Notes { get; set; } +} + +public class UpdateOfferingDto +{ + public string? PartNumber { get; set; } + public string? SupplierDescription { get; set; } + public decimal? Price { get; set; } + public string? Notes { get; set; } +}