Compare commits

..

8 Commits

Author SHA1 Message Date
aj 2a94ad63cb fix: Correct indentation in MaterialService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:58 -05:00
aj b0a9d7fdcc docs: Add descriptive intro text to index pages
Adds brief explanatory paragraphs to Jobs, Materials, and Stock index
pages to help users understand each section's purpose.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:53 -05:00
aj f20770d03e style: Update UI with warmer, softer color palette
Replace default Bootstrap grays with warm off-whites and subtle borders.
Adds consistent styling for cards, forms, headings, and tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:48 -05:00
aj 4aec4c2275 feat: Add bulk stock import modal to job editor
Allows importing multiple stock items from inventory at once, matching
against materials already in the job's parts list. Includes select
all/none, quantity, and priority controls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:42 -05:00
aj 261f64a895 chore: Remove MCP server build artifacts from repo
These compiled binaries and runtime files should not be tracked in source control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:36 -05:00
aj 9b757acac3 fix: Correct TPH discriminator values and empty MaterialType
Fix two data issues preventing material loading:
- Update MaterialDimensions DimensionType from class names (e.g.
  'AngleDimensions') to configured short names (e.g. 'Angle')
- Set empty Material.Type values to 'Steel' and change column default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 16:54:17 -05:00
aj 177affabf0 refactor: Decouple MCP server from direct DB access
Replace direct EF Core/DbContext usage in MCP tools with HTTP calls
to the CutList.Web REST API via new ApiClient. Removes CutList.Web
project reference from MCP, adds Microsoft.Extensions.Http instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 16:54:05 -05:00
aj 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
116 changed files with 4497 additions and 2200 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,20 +0,0 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
Binary file not shown.
Binary file not shown.
@@ -1,21 +0,0 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}
+214
View File
@@ -0,0 +1,214 @@
using System.Net.Http.Json;
namespace CutList.Mcp;
/// <summary>
/// Typed HTTP client for calling the CutList.Web REST API.
/// </summary>
public class ApiClient
{
private readonly HttpClient _http;
public ApiClient(HttpClient http)
{
_http = http;
}
#region Suppliers
public async Task<List<ApiSupplierDto>> GetSuppliersAsync(bool includeInactive = false)
{
var url = $"api/suppliers?includeInactive={includeInactive}";
return await _http.GetFromJsonAsync<List<ApiSupplierDto>>(url) ?? [];
}
public async Task<ApiSupplierDto?> CreateSupplierAsync(string name, string? contactInfo, string? notes)
{
var response = await _http.PostAsJsonAsync("api/suppliers", new { Name = name, ContactInfo = contactInfo, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiSupplierDto>();
}
#endregion
#region Materials
public async Task<List<ApiMaterialDto>> GetMaterialsAsync(string? shape = null, bool includeInactive = false)
{
var url = $"api/materials?includeInactive={includeInactive}";
if (!string.IsNullOrEmpty(shape))
url += $"&shape={Uri.EscapeDataString(shape)}";
return await _http.GetFromJsonAsync<List<ApiMaterialDto>>(url) ?? [];
}
public async Task<ApiMaterialDto?> CreateMaterialAsync(string shape, string? size, string? description,
string? type, string? grade, Dictionary<string, decimal>? dimensions)
{
var body = new
{
Shape = shape,
Size = size,
Description = description,
Type = type,
Grade = grade,
Dimensions = dimensions
};
var response = await _http.PostAsJsonAsync("api/materials", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiMaterialDto>();
}
public async Task<List<ApiMaterialDto>> SearchMaterialsAsync(string shape, decimal targetValue, decimal tolerance)
{
var response = await _http.PostAsJsonAsync("api/materials/search", new
{
Shape = shape,
TargetValue = targetValue,
Tolerance = tolerance
});
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<ApiMaterialDto>>() ?? [];
}
#endregion
#region Stock Items
public async Task<List<ApiStockItemDto>> GetStockItemsAsync(int? materialId = null, bool includeInactive = false)
{
var url = $"api/stock-items?includeInactive={includeInactive}";
if (materialId.HasValue)
url += $"&materialId={materialId.Value}";
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? [];
}
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, int quantityOnHand, string? notes)
{
var body = new
{
MaterialId = materialId,
Length = length,
Name = name,
QuantityOnHand = quantityOnHand,
Notes = notes
};
var response = await _http.PostAsJsonAsync("api/stock-items", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiStockItemDto>();
}
#endregion
#region Offerings
public async Task<List<ApiOfferingDto>> GetOfferingsForSupplierAsync(int supplierId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/suppliers/{supplierId}/offerings") ?? [];
}
public async Task<List<ApiOfferingDto>> GetOfferingsForStockItemAsync(int stockItemId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/stock-items/{stockItemId}/offerings") ?? [];
}
public async Task<ApiOfferingDto?> CreateOfferingAsync(int supplierId, int stockItemId,
string? partNumber, string? supplierDescription, decimal? price, string? notes)
{
var body = new
{
StockItemId = stockItemId,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price,
Notes = notes
};
var response = await _http.PostAsJsonAsync($"api/suppliers/{supplierId}/offerings", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiOfferingDto>();
}
#endregion
}
/// <summary>
/// Thrown when the API returns 409 Conflict (duplicate resource).
/// </summary>
public class ApiConflictException : Exception
{
public ApiConflictException(string message) : base(message) { }
}
#region API Response DTOs
public class ApiSupplierDto
{
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 ApiMaterialDto
{
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 ApiMaterialDimensionsDto? Dimensions { get; set; }
}
public class ApiMaterialDimensionsDto
{
public string DimensionType { get; set; } = string.Empty;
public Dictionary<string, decimal> Values { get; set; } = new();
}
public class ApiStockItemDto
{
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 ApiOfferingDto
{
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; }
}
#endregion
+1 -1
View File
@@ -2,11 +2,11 @@
<ItemGroup>
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
<ProjectReference Include="..\CutList.Web\CutList.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="ModelContextProtocol" Version="0.7.0-preview.1" />
</ItemGroup>
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,14 +1,15 @@
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using CutList.Mcp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
// Add DbContext for inventory tools
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"));
// Register HttpClient for API calls to CutList.Web
builder.Services.AddHttpClient<ApiClient>(client =>
{
client.BaseAddress = new Uri("http://localhost:5009");
});
builder.Services
.AddMcpServer()
+210 -3
View File
@@ -143,6 +143,102 @@ else
</div>
}
@* Import Stock Modal *@
@if (showImportModal)
{
<div class="modal fade show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import Stock from Inventory</h5>
<button type="button" class="btn-close" @onclick="CloseImportModal"></button>
</div>
<div class="modal-body">
@if (loadingImport)
{
<div class="text-center py-4">
<span class="spinner-border"></span>
<p class="mt-2 text-muted">Finding matching stock...</p>
</div>
}
else if (importCandidates.Count == 0)
{
<div class="text-center py-4 text-muted">
<p class="mb-2">No matching inventory stock found.</p>
<p class="small">Either no stock items exist for the materials in your parts, or they have already been added to this job.</p>
</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(true)">Select All</button>
<button class="btn btn-outline-secondary" @onclick="() => ToggleAllImportCandidates(false)">Select None</button>
</div>
<small class="text-muted">@importCandidates.Count(c => c.Selected) of @importCandidates.Count selected</small>
</div>
@foreach (var group in importCandidates
.GroupBy(c => c.StockItem.MaterialId)
.OrderBy(g => g.First().StockItem.Material.Shape)
.ThenBy(g => g.First().StockItem.Material.Size))
{
var material = group.First().StockItem.Material;
<h6 class="mt-3 mb-2 text-primary">@material.DisplayName</h6>
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Length</th>
<th>On Hand</th>
<th style="width: 120px;">Qty to Use</th>
<th style="width: 100px;">Priority</th>
</tr>
</thead>
<tbody>
@foreach (var candidate in group.OrderByDescending(c => c.StockItem.LengthInches))
{
<tr class="@(candidate.Selected ? "" : "text-muted")">
<td>
<input type="checkbox" class="form-check-input" @bind="candidate.Selected" />
</td>
<td>@ArchUnits.FormatFromInches((double)candidate.StockItem.LengthInches)</td>
<td>@candidate.StockItem.QuantityOnHand</td>
<td>
<input type="number" class="form-control form-control-sm" @bind="candidate.Quantity"
min="-1" disabled="@(!candidate.Selected)" />
<small class="text-muted">-1 = unlimited</small>
</td>
<td>
<input type="number" class="form-control form-control-sm" @bind="candidate.Priority"
min="1" disabled="@(!candidate.Selected)" />
</td>
</tr>
}
</tbody>
</table>
}
}
@if (!string.IsNullOrEmpty(importErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@importErrorMessage</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseImportModal">Cancel</button>
@if (importCandidates.Count > 0)
{
<button type="button" class="btn btn-primary" @onclick="ImportSelectedStockAsync"
disabled="@(!importCandidates.Any(c => c.Selected))">
Import @importCandidates.Count(c => c.Selected) Item@(importCandidates.Count(c => c.Selected) != 1 ? "s" : "")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
private enum Tab { Details, Parts, Stock }
@@ -177,6 +273,12 @@ else
private int stockSelectedMaterialId;
private List<StockItem> availableStockItems = new();
// Import modal
private bool showImportModal;
private bool loadingImport;
private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
? Enumerable.Empty<Material>()
@@ -443,9 +545,15 @@ else
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock for This Job</h5>
<div class="btn-group">
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<div class="btn-group">
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
</div>
</div>
<div class="card-body">
@@ -819,4 +927,103 @@ else
await JobService.DeleteStockAsync(stock.Id);
job = (await JobService.GetByIdAsync(Id!.Value))!;
}
// Import modal methods
private async Task ShowImportModal()
{
importErrorMessage = null;
importCandidates.Clear();
loadingImport = true;
showImportModal = true;
try
{
var materialIds = job.Parts.Select(p => p.MaterialId).Distinct().ToList();
var existingStockItemIds = job.Stock
.Where(s => s.StockItemId.HasValue)
.Select(s => s.StockItemId!.Value)
.ToHashSet();
foreach (var materialId in materialIds)
{
var stockItems = await JobService.GetAvailableStockForMaterialAsync(materialId);
foreach (var item in stockItems.Where(s => !existingStockItemIds.Contains(s.Id)))
{
importCandidates.Add(new ImportStockCandidate
{
StockItem = item,
Selected = true,
Quantity = -1,
Priority = 10
});
}
}
}
catch (Exception ex)
{
importErrorMessage = $"Error loading stock: {ex.Message}";
}
finally
{
loadingImport = false;
}
}
private void CloseImportModal()
{
showImportModal = false;
importCandidates.Clear();
importErrorMessage = null;
}
private void ToggleAllImportCandidates(bool selected)
{
foreach (var c in importCandidates)
c.Selected = selected;
}
private async Task ImportSelectedStockAsync()
{
importErrorMessage = null;
var selected = importCandidates.Where(c => c.Selected).ToList();
if (selected.Count == 0)
{
importErrorMessage = "No items selected";
return;
}
try
{
foreach (var candidate in selected)
{
var jobStock = new JobStock
{
JobId = Id!.Value,
MaterialId = candidate.StockItem.MaterialId,
StockItemId = candidate.StockItem.Id,
LengthInches = candidate.StockItem.LengthInches,
Quantity = candidate.Quantity,
Priority = candidate.Priority,
IsCustomLength = false
};
await JobService.AddStockAsync(jobStock);
}
job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false;
importCandidates.Clear();
}
catch (Exception ex)
{
importErrorMessage = $"Error importing stock: {ex.Message}";
}
}
private class ImportStockCandidate
{
public StockItem StockItem { get; set; } = null!;
public bool Selected { get; set; } = true;
public int Quantity { get; set; } = -1;
public int Priority { get; set; } = 10;
}
}
@@ -18,6 +18,11 @@
</div>
</div>
<p class="text-muted mb-4">
Jobs organize the parts you need to cut for a project. Add parts with their required lengths and quantities,
assign stock materials, then run the optimizer to generate an efficient cut list that minimizes waste.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
@@ -9,6 +9,11 @@
<a href="materials/new" class="btn btn-primary">Add Material</a>
</div>
<p class="text-muted mb-4">
Manage your material catalog here. Materials define the types of stock you work with — shape, size, type, and
grade. Once added, materials can be assigned to jobs and used to generate optimized cut lists.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
@@ -11,6 +11,11 @@
<a href="stock/new" class="btn btn-primary">Add Stock Item</a>
</div>
<p class="text-muted mb-4">
Stock items represent the specific lengths of material you have available for cutting. Each stock item links
a material to a length and tracks how many pieces you have on hand.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
@@ -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
};
}
+492
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()
};
}
+296 -87
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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More