Files
CutList/CutList.Web/Controllers/JobsController.cs
AJ Isaacs 17f16901ef 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>
2026-02-05 16:53:53 -05:00

493 lines
16 KiB
C#

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()
};
}