feat: Add full REST API with controllers, DTOs, and service layer

Add controllers for suppliers, stock items, jobs, cutting tools, and
packing. Refactor MaterialsController to use MaterialService with
dimension-aware CRUD, search, and bulk operations. Extract DTOs into
dedicated files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 16:53:53 -05:00
parent 21d50e7c20
commit 17f16901ef
13 changed files with 1919 additions and 87 deletions

View File

@@ -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<ActionResult<List<CuttingToolDto>>> GetAll([FromQuery] bool includeInactive = false)
{
var tools = await _jobService.GetCuttingToolsAsync(includeInactive);
return Ok(tools.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<CuttingToolDto>> GetById(int id)
{
var tool = await _jobService.GetCuttingToolByIdAsync(id);
if (tool == null)
return NotFound();
return Ok(MapToDto(tool));
}
[HttpGet("default")]
public async Task<ActionResult<CuttingToolDto>> GetDefault()
{
var tool = await _jobService.GetDefaultCuttingToolAsync();
if (tool == null)
return NotFound();
return Ok(MapToDto(tool));
}
[HttpPost]
public async Task<ActionResult<CuttingToolDto>> 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<ActionResult<CuttingToolDto>> 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<IActionResult> 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
};
}

View File

@@ -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<ActionResult<List<JobDto>>> GetAll()
{
var jobs = await _jobService.GetAllAsync();
return Ok(jobs.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<JobDetailDto>> GetById(int id)
{
var job = await _jobService.GetByIdAsync(id);
if (job == null)
return NotFound();
return Ok(MapToDetailDto(job));
}
[HttpPost]
public async Task<ActionResult<JobDetailDto>> 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<ActionResult<JobDetailDto>> 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<ActionResult<JobDetailDto>> 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<IActionResult> 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<ActionResult<JobDetailDto>> 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<ActionResult<List<JobPartDto>>> 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<ActionResult<JobPartDto>> 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<ActionResult<JobPartDto>> 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<IActionResult> 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<ActionResult<List<JobStockDto>>> 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<ActionResult<JobStockDto>> 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<ActionResult<JobStockDto>> 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<IActionResult> 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<ActionResult<List<StockItemDto>>> 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<ActionResult<PackResponseDto>> 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()
};
}

View File

@@ -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<ActionResult<IEnumerable<MaterialDto>>> GetMaterials()
public async Task<ActionResult<List<MaterialDto>>> 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<Material> 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<ActionResult<MaterialDto>> 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<ActionResult<List<MaterialDto>>> 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<MaterialType>(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<MaterialType>(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<IActionResult> DeleteMaterial(int id)
[HttpPut("{id}")]
public async Task<ActionResult<MaterialDto>> 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<MaterialType>(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<IActionResult> 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<ActionResult<List<MaterialDto>>> 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<string> 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<Material>()
};
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<string, decimal> 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;
}
}
}

View File

@@ -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<object> 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<BinItem>();
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<MultiBin>();
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<ParseLengthResponseDto> 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<FormatLengthResponseDto> FormatLength(FormatLengthRequestDto dto)
{
return Ok(new FormatLengthResponseDto
{
Inches = dto.Inches,
Formatted = ArchUnits.FormatFromInches(dto.Inches)
});
}
}

View File

@@ -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<ActionResult<List<StockItemDto>>> GetAll(
[FromQuery] bool includeInactive = false,
[FromQuery] int? materialId = null)
{
List<StockItem> 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<ActionResult<StockItemDto>> GetById(int id)
{
var item = await _stockItemService.GetByIdAsync(id);
if (item == null)
return NotFound();
return Ok(MapToDto(item));
}
[HttpPost]
public async Task<ActionResult<StockItemDto>> 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<ActionResult<StockItemDto>> 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<IActionResult> 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<ActionResult<List<StockItemDto>>> GetByMaterial(int materialId)
{
var items = await _stockItemService.GetByMaterialAsync(materialId);
return Ok(items.Select(MapToDto).ToList());
}
[HttpGet("{id}/offerings")]
public async Task<ActionResult<List<OfferingDto>>> 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<ActionResult<StockPricingDto>> 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<ActionResult<List<StockTransactionDto>>> 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<ActionResult<StockTransactionDto>> 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<ActionResult<StockTransactionDto>> 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<ActionResult<StockTransactionDto>> 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<ActionResult<StockTransactionDto>> 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<ActionResult<object>> 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
};
}

View File

@@ -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<ActionResult<List<SupplierDto>>> GetAll([FromQuery] bool includeInactive = false)
{
var suppliers = await _supplierService.GetAllAsync(includeInactive);
return Ok(suppliers.Select(MapToDto).ToList());
}
[HttpGet("{id}")]
public async Task<ActionResult<SupplierDto>> GetById(int id)
{
var supplier = await _supplierService.GetByIdAsync(id);
if (supplier == null)
return NotFound();
return Ok(MapToDto(supplier));
}
[HttpPost]
public async Task<ActionResult<SupplierDto>> 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<ActionResult<SupplierDto>> 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<IActionResult> 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<ActionResult<List<OfferingDto>>> 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<ActionResult<OfferingDto>> 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<ActionResult<OfferingDto>> 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<IActionResult> 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
};
}

View File

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

111
CutList.Web/DTOs/JobDtos.cs Normal file
View File

@@ -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<JobPartDto> Parts { get; set; } = new();
public List<JobStockDto> 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; }
}

View File

@@ -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<string, decimal>? 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<string, decimal>? Dimensions { get; set; }
}
public class BulkCreateResult
{
public int Created { get; set; }
public int Skipped { get; set; }
public List<string> Errors { get; set; } = new();
}
public class MaterialDimensionsDto
{
public string DimensionType { get; set; } = string.Empty;
public Dictionary<string, decimal> 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;
}

View File

@@ -0,0 +1,66 @@
namespace CutList.Web.DTOs;
public class PackResponseDto
{
public List<MaterialPackResultDto> 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<PackedBinDto> InStockBins { get; set; } = new();
public List<PackedBinDto> ToBePurchasedBins { get; set; } = new();
public List<PackedItemDto> 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<PackedItemDto> 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<MaterialPackingSummaryDto> 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; }
}

View File

@@ -0,0 +1,45 @@
namespace CutList.Web.DTOs;
public class StandalonePackRequestDto
{
public List<PartInputDto> Parts { get; set; } = new();
public List<StockBinInputDto> 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; }
}

View File

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

View File

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