Compare commits

...

6 Commits

Author SHA1 Message Date
2fdf006a8e docs: Update CLAUDE.md with optimization persistence and Results tab
Document new Job entity fields, serialization DTOs, JobService
optimization methods, and merged Results tab in Edit page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:13:12 -05:00
eee38a8473 chore: Add Windows Service deployment script
PowerShell script to publish CutList.Web, register as a Windows
Service with auto-restart on failure, and optionally open firewall.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:13:05 -05:00
59f86c8e79 refactor: Merge Results page into Job Edit as a tab
Move optimization results UI from separate Results.razor page into
the Edit.razor tabbed editor. Results are now loaded from saved JSON
on page load instead of re-running on every visit. Remove the
standalone optimize button from Jobs index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:12:57 -05:00
891b214b29 feat: Add serialization DTOs for optimization results
Add SavedOptimizationResult DTO layer with SerializeResult and
LoadSavedResult methods for JSON round-trip persistence, since
Core types use encapsulated collections that aren't serializable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:12:47 -05:00
c5f366a3ef feat: Add optimization result persistence to Job entity
Add OptimizationResultJson and OptimizedAt columns to Job table.
JobService now saves/clears optimization results and auto-clears
stale results when parts, stock, or cutting tool change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:12:38 -05:00
8926d44969 perf: Add lower-bound pruning to ExhaustiveFitEngine
Precompute suffix sums of remaining item volumes and use them
to prune branches that cannot beat the current best solution.
Raises DefaultMaxItems from 20 to 25 (~84ms worst case).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:12:28 -05:00
13 changed files with 1699 additions and 376 deletions

View File

@@ -115,6 +115,8 @@ Abstract base; derived types per shape: `RoundBarDimensions`, `RoundTubeDimensio
### Job
- `JobNumber` (auto-generated "JOB-#####", unique), `Name`, `Customer`, `CuttingToolId`, `Notes`
- `LockedAt` (DateTime?) — set when materials ordered; `IsLocked` computed property
- `OptimizationResultJson` (string?, nvarchar(max)) — serialized optimization results
- `OptimizedAt` (DateTime?) — when optimization was last run
- **Relationships**: `Parts` (1:many JobPart), `Stock` (1:many JobStock), `CuttingTool`
### JobPart
@@ -146,14 +148,16 @@ Abstract base; derived types per shape: `RoundBarDimensions`, `RoundTubeDimensio
### JobService
- Job CRUD: `CreateAsync` (auto-generates JobNumber), `DuplicateAsync` (deep copy), `QuickCreateAsync`
- Lock/Unlock: `LockAsync(id)`, `UnlockAsync(id)` — controls job editability after ordering
- Parts: `AddPartAsync`, `UpdatePartAsync`, `DeletePartAsync` (all update job timestamp)
- Stock: `AddStockAsync`, `UpdateStockAsync`, `DeleteStockAsync`
- Parts: `AddPartAsync`, `UpdatePartAsync`, `DeletePartAsync` (all update job timestamp + clear optimization results)
- Stock: `AddStockAsync`, `UpdateStockAsync`, `DeleteStockAsync` (all clear optimization results)
- Optimization: `SaveOptimizationResultAsync`, `ClearOptimizationResultAsync`
- Cutting tools: full CRUD with single-default enforcement
### CutListPackingService
- `PackAsync(parts, kerfInches, jobStock?)` — runs optimization per material group
- Separates results into `InStockBins` (from inventory) and `ToBePurchasedBins`
- `GetSummary(result)` — calculates total bins, pieces, waste, efficiency %
- `SerializeResult(result)` / `LoadSavedResult(json)` — JSON round-trip via DTO layer (`SavedOptimizationResult` etc.)
### PurchaseItemService
- CRUD + `CreateBulkAsync` for batch creation from optimization results
@@ -169,8 +173,7 @@ Abstract base; derived types per shape: `RoundBarDimensions`, `RoundTubeDimensio
| `/` | Home | Welcome page with feature cards and workflow guide |
| `/jobs` | Jobs/Index | Job list with pagination, lock icons, Quick Create, Duplicate, Delete |
| `/jobs/new` | Jobs/Edit | New job form (details only) |
| `/jobs/{Id}` | Jobs/Edit | Tabbed editor (Details, Parts, Stock); locked jobs show banner + disable editing |
| `/jobs/{Id}/results` | Jobs/Results | Optimization results, summary cards, "Add to Order List" (locks job), Print Report |
| `/jobs/{Id}` | Jobs/Edit | Tabbed editor (Details, Parts, Stock, Results); locked jobs show banner + disable editing |
| `/materials` | Materials/Index | Material list with MaterialFilter, pagination |
| `/materials/new`, `/materials/{Id}` | Materials/Edit | Material + dimension form (varies by shape) |
| `/stock` | Stock/Index | Stock items with MaterialFilter, quantity badges |
@@ -200,6 +203,7 @@ Abstract base; derived types per shape: `RoundBarDimensions`, `RoundTubeDimensio
- **Material selection flow** — Shape dropdown -> Size dropdown -> Length input -> Quantity (conditional dropdowns)
- **Stock priority** — Lower number = used first; `-1` quantity = unlimited
- **Job stock** — Jobs can use auto-discovered inventory OR define custom stock lengths
- **Optimization persistence** — Results saved as JSON in `Job.OptimizationResultJson`; DTO layer (`SavedOptimizationResult` etc.) handles serialization since Core types use encapsulated collections; results auto-cleared when parts, stock, or cutting tool change
- **Purchase flow** — Optimize job -> "Add to Order List" creates PurchaseItems + locks job -> Orders page manages status (Pending -> Ordered -> Received)
- **Timestamps** — `CreatedAt` defaults to `GETUTCDATE()`; `UpdatedAt` set on modifications
- **Collections** — Encapsulated in Core; use `AsReadOnly()`, access via `Add*` methods
@@ -216,6 +220,5 @@ Abstract base; derived types per shape: `RoundBarDimensions`, `RoundTubeDimensio
| `CutList.Web/Data/ApplicationDbContext.cs` | EF Core context with all DbSets and configuration |
| `CutList.Web/Services/JobService.cs` | Job orchestration (CRUD, parts, stock, tools, lock/unlock) |
| `CutList.Web/Services/CutListPackingService.cs` | Bridges web entities to Core packing engine |
| `CutList.Web/Components/Pages/Jobs/Edit.razor` | Job editor (tabbed: Details, Parts, Stock) |
| `CutList.Web/Components/Pages/Jobs/Results.razor` | Optimization results + order creation |
| `CutList.Web/Components/Pages/Jobs/Edit.razor` | Job editor (tabbed: Details, Parts, Stock, Results) |
| `CutList/Presenters/MainFormPresenter.cs` | WinForms business logic orchestrator |

View File

@@ -9,9 +9,9 @@ namespace CutList.Core.Nesting
{
/// <summary>
/// Default maximum number of items before falling back to AdvancedFitEngine.
/// Testing showed 20 items is safe (~100ms worst case), while 21+ can take seconds.
/// Testing showed 25 items is safe (~84ms worst case), while 30+ can take seconds.
/// </summary>
public const int DefaultMaxItems = 20;
public const int DefaultMaxItems = 25;
private readonly IEngine _fallbackEngine;
private readonly int _maxItems;
@@ -67,7 +67,15 @@ namespace CutList.Core.Nesting
BinCount = 0
};
Search(sortedItems, 0, currentState, bestSolution, request);
// Precompute suffix sums of item lengths (including spacing per item)
// for lower-bound pruning. suffixVolume[i] = total volume of items[i..n-1].
var suffixVolume = new double[sortedItems.Count + 1];
for (int i = sortedItems.Count - 1; i >= 0; i--)
{
suffixVolume[i] = suffixVolume[i + 1] + sortedItems[i].Length + request.Spacing;
}
Search(sortedItems, 0, currentState, bestSolution, request, suffixVolume);
// Build result from best solution
var result = new PackResult();
@@ -101,7 +109,8 @@ namespace CutList.Core.Nesting
int itemIndex,
SearchState current,
SearchState best,
PackingRequest request)
PackingRequest request,
double[] suffixVolume)
{
// All items placed - check if this is better
if (itemIndex >= items.Count)
@@ -123,6 +132,18 @@ namespace CutList.Core.Nesting
if (current.BinCount >= request.MaxBinCount)
return;
// Lower-bound pruning: remaining items need at least this many additional bins
double remainingVolume = suffixVolume[itemIndex];
double availableInExisting = 0;
for (int b = 0; b < current.Bins.Count; b++)
{
availableInExisting += request.StockLength - GetBinUsedLength(current.Bins[b], request.Spacing);
}
double overflow = remainingVolume - availableInExisting;
int additionalBinsNeeded = overflow > 0 ? (int)Math.Ceiling(overflow / request.StockLength) : 0;
if (current.BinCount + additionalBinsNeeded >= best.BinCount)
return;
var item = items[itemIndex];
// Symmetry breaking: if this item has the same length as the previous item,
@@ -148,7 +169,7 @@ namespace CutList.Core.Nesting
current.Bins[i].Add(item);
var prevBinIndex = current.LastBinIndexUsed;
current.LastBinIndexUsed = i;
Search(items, itemIndex + 1, current, best, request);
Search(items, itemIndex + 1, current, best, request, suffixVolume);
current.LastBinIndexUsed = prevBinIndex;
current.Bins[i].RemoveAt(current.Bins[i].Count - 1);
}
@@ -162,7 +183,7 @@ namespace CutList.Core.Nesting
current.BinCount++;
var prevBinIndex = current.LastBinIndexUsed;
current.LastBinIndexUsed = newBinIndex;
Search(items, itemIndex + 1, current, best, request);
Search(items, itemIndex + 1, current, best, request, suffixVolume);
current.LastBinIndexUsed = prevBinIndex;
current.Bins.RemoveAt(current.Bins.Count - 1);
current.BinCount--;

View File

@@ -3,7 +3,12 @@
@inject JobService JobService
@inject MaterialService MaterialService
@inject StockItemService StockItemService
@inject CutListPackingService PackingService
@inject PurchaseItemService PurchaseItemService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@using CutList.Core
@using CutList.Core.Nesting
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
@@ -11,10 +16,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>@(IsNew ? "New Job" : job.DisplayName)</h1>
@if (!IsNew)
{
<a href="jobs/@Id/results" class="btn btn-success">Run Optimization</a>
}
<a href="jobs" class="btn btn-outline-secondary">Back to Jobs</a>
</div>
@if (!IsNew && job.IsLocked)
@@ -73,6 +75,16 @@ else
}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(activeTab == Tab.Results ? "active" : "")"
@onclick="() => SetTab(Tab.Results)" type="button">
Results
@if (summary != null)
{
<span class="badge bg-success ms-1">@summary.Efficiency.ToString("F0")%</span>
}
</button>
</li>
</ul>
<div class="tab-content">
@@ -92,6 +104,10 @@ else
{
@RenderStockTab()
}
else if (activeTab == Tab.Results)
{
@RenderResultsTab()
}
</div>
}
@@ -253,7 +269,7 @@ else
}
@code {
private enum Tab { Details, Parts, Stock }
private enum Tab { Details, Parts, Stock, Results }
[Parameter]
public int? Id { get; set; }
@@ -292,12 +308,20 @@ else
private List<ImportStockCandidate> importCandidates = new();
private string? importErrorMessage;
// Results tab
private MultiMaterialPackResult? packResult;
private MultiMaterialPackingSummary? summary;
private bool optimizing;
private bool addingToOrderList;
private bool addedToOrderList;
private IEnumerable<MaterialShape> DistinctShapes => materials.Select(m => m.Shape).Distinct().OrderBy(s => s);
private IEnumerable<Material> FilteredMaterials => !selectedShape.HasValue
? Enumerable.Empty<Material>()
: materials.Where(m => m.Shape == selectedShape.Value).OrderBy(m => m.SortOrder).ThenBy(m => m.Size);
private bool IsNew => !Id.HasValue;
private bool CanOptimize => job.Parts.Count > 0 && job.CuttingToolId != null;
private async Task UnlockJob()
{
@@ -322,6 +346,12 @@ else
return;
}
job = existing;
// Load saved optimization results if available
if (job.OptimizationResultJson != null)
{
LoadSavedResults();
}
}
else
{
@@ -336,6 +366,25 @@ else
loading = false;
}
private void LoadSavedResults()
{
try
{
packResult = PackingService.LoadSavedResult(job.OptimizationResultJson!);
if (packResult != null)
{
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
}
catch
{
// Invalid JSON — treat as no results
packResult = null;
summary = null;
}
}
private RenderFragment RenderDetailsForm() => __builder =>
{
<div class="card">
@@ -474,6 +523,10 @@ else
else
{
await JobService.UpdateAsync(job);
job = (await JobService.GetByIdAsync(Id!.Value))!;
// Clear in-memory results since they were invalidated
packResult = null;
summary = null;
}
}
finally
@@ -561,12 +614,17 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showPartForm = false;
editingPart = null;
// Results were cleared by the service
packResult = null;
summary = null;
}
private async Task DeletePart(JobPart part)
{
await JobService.DeletePartAsync(part.Id);
job = (await JobService.GetByIdAsync(Id!.Value))!;
packResult = null;
summary = null;
}
// Stock tab
@@ -582,10 +640,7 @@ else
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>
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
}
</div>
@@ -777,16 +832,341 @@ else
</div>
};
private void ShowAddStockFromInventory()
// Results tab
private RenderFragment RenderResultsTab() => __builder =>
{
editingStock = null;
newStock = new JobStock { JobId = Id!.Value, Quantity = 1, Priority = 10 };
stockSelectedShape = null;
stockSelectedMaterialId = 0;
availableStockItems.Clear();
showStockForm = true;
showCustomStockForm = false;
stockErrorMessage = null;
@if (!CanOptimize)
{
<div class="alert alert-warning">
<h5 class="mb-2">Cannot Optimize</h5>
<ul class="mb-0">
@if (job.Parts.Count == 0)
{
<li>No parts defined. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Parts)">Parts tab</button> to add parts.</li>
}
@if (job.CuttingToolId == null)
{
<li>No cutting tool selected. Switch to the <button class="btn btn-link p-0" @onclick="() => SetTab(Tab.Details)">Details tab</button> to select a cutting tool.</li>
}
</ul>
</div>
}
else
{
<div class="mb-3">
<button class="btn btn-success" @onclick="RunOptimization" disabled="@(optimizing || job.IsLocked)">
@if (optimizing)
{
<span class="spinner-border spinner-border-sm me-1"></span>
<text>Optimizing...</text>
}
else if (packResult != null)
{
<i class="bi bi-arrow-clockwise me-1"></i>
<text>Re-Optimize</text>
}
else
{
<i class="bi bi-scissors me-1"></i>
<text>Optimize</text>
}
</button>
@if (packResult != null)
{
<button class="btn btn-outline-secondary ms-2" @onclick="PrintReport">
<i class="bi bi-printer me-1"></i> Print Report
</button>
}
@if (job.OptimizedAt.HasValue)
{
<span class="text-muted ms-3">
Last optimized: @job.OptimizedAt.Value.ToLocalTime().ToString("g")
</span>
}
</div>
@if (packResult != null && summary != null)
{
@if (summary.TotalItemsNotPlaced > 0)
{
<div class="alert alert-warning">
<h5>Items Not Placed</h5>
<p class="mb-0">Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.</p>
</div>
}
<!-- Overall Summary Cards -->
<div class="row mb-4 print-summary">
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)</h2>
<p class="card-text text-muted">Total Stock Bars</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
<p class="card-text text-muted">Total Pieces</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
<p class="card-text text-muted">Total Waste</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
<p class="card-text text-muted">Efficiency</p>
</div>
</div>
</div>
</div>
<!-- Stock Summary -->
<div class="row mb-4 print-stock-summary">
<div class="col-md-6 mb-3">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0">In Stock</h5>
</div>
<div class="card-body">
<h3>@summary.TotalInStockBins bars</h3>
<p class="text-muted mb-0">Ready to cut from existing inventory</p>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card border-warning">
<div class="card-header bg-warning">
<h5 class="mb-0">To Be Purchased</h5>
</div>
<div class="card-body">
<h3>@summary.TotalToBePurchasedBins bars</h3>
@if (summary.TotalToBePurchasedBins > 0)
{
@if (addedToOrderList)
{
<div class="alert alert-success mb-0 mt-2 py-2">
Added to order list. <a href="orders">View Orders</a>
</div>
}
else
{
<button class="btn btn-warning btn-sm mt-2" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus"></i> Add to Order List
</button>
}
}
else
{
<p class="text-muted mb-0">Everything available in stock</p>
}
</div>
</div>
</div>
</div>
<!-- Results by Material -->
@foreach (var materialResult in packResult.MaterialResults)
{
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
<div class="card mb-4">
<div class="card-header">
<h4 class="mb-0">@materialResult.Material.DisplayName</h4>
</div>
<div class="card-body">
<!-- Material Summary -->
<div class="row mb-3">
<div class="col-md-2 col-4">
<strong>@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins)</strong> bars
</div>
<div class="col-md-2 col-4">
<strong>@materialSummary.TotalPieces</strong> pieces
</div>
<div class="col-md-2 col-4">
<strong>@materialSummary.Efficiency.ToString("F1")%</strong> efficiency
</div>
<div class="col-md-3 col-6">
<span class="text-success">@materialSummary.InStockBins in stock</span>
</div>
<div class="col-md-3 col-6">
<span class="text-warning">@materialSummary.ToBePurchasedBins to purchase</span>
</div>
</div>
@if (materialResult.PackResult.ItemsNotUsed.Count > 0)
{
<div class="alert alert-danger">
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> -
No stock lengths available or parts too long.
</div>
}
@if (materialResult.InStockBins.Count > 0)
{
<h5 class="text-success mt-3">In Stock (@materialResult.InStockBins.Count bars)</h5>
@RenderBinList(materialResult.InStockBins)
}
@if (materialResult.ToBePurchasedBins.Count > 0)
{
<h5 class="text-warning mt-3">To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)</h5>
@RenderBinList(materialResult.ToBePurchasedBins)
<!-- Purchase Summary -->
<div class="mt-3 p-3 bg-light rounded">
<strong>Order Summary:</strong>
<ul class="mb-0 mt-2">
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
{
<li>@group.Count() x @ArchUnits.FormatFromInches(group.Key)</li>
}
</ul>
</div>
}
</div>
</div>
}
}
else if (!optimizing)
{
<div class="text-center py-5 text-muted">
<i class="bi bi-scissors display-4"></i>
<p class="mt-3">Click <strong>Optimize</strong> to calculate the most efficient cut list.</p>
</div>
}
}
};
private RenderFragment RenderBinList(List<Bin> bins) => __builder =>
{
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th style="width: 80px;">#</th>
<th>Stock Length</th>
<th>Cuts</th>
<th>Waste</th>
</tr>
</thead>
<tbody>
@{ var binNumber = 1; }
@foreach (var bin in bins)
{
<tr>
<td>@binNumber</td>
<td>@ArchUnits.FormatFromInches(bin.Length)</td>
<td>
@foreach (var item in bin.Items)
{
<span class="badge bg-primary me-1">
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
</span>
}
</td>
<td>@ArchUnits.FormatFromInches(bin.RemainingLength)</td>
</tr>
binNumber++;
}
</tbody>
</table>
</div>
};
private async Task RunOptimization()
{
optimizing = true;
try
{
var kerf = job.CuttingTool?.KerfInches ?? 0.125m;
packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null);
summary = PackingService.GetSummary(packResult);
// Save to database
var json = PackingService.SerializeResult(packResult);
await JobService.SaveOptimizationResultAsync(Id!.Value, json, DateTime.UtcNow);
// Refresh job to get updated OptimizedAt
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = job.IsLocked;
}
finally
{
optimizing = false;
}
}
private async Task AddToOrderList()
{
addingToOrderList = true;
try
{
var purchaseItems = new List<PurchaseItem>();
var stockItems = await StockItemService.GetAllAsync();
foreach (var materialResult in packResult!.MaterialResults)
{
if (materialResult.ToBePurchasedBins.Count == 0) continue;
var materialId = materialResult.Material.Id;
// Group bins by length to consolidate quantities
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
{
var lengthInches = (decimal)group.Key;
var quantity = group.Count();
// Find the matching stock item
var stockItem = stockItems.FirstOrDefault(s =>
s.MaterialId == materialId && s.LengthInches == lengthInches);
if (stockItem != null)
{
purchaseItems.Add(new PurchaseItem
{
StockItemId = stockItem.Id,
Quantity = quantity,
JobId = Id!.Value,
Status = PurchaseItemStatus.Pending
});
}
}
}
if (purchaseItems.Count > 0)
{
await PurchaseItemService.CreateBulkAsync(purchaseItems);
}
await JobService.LockAsync(Id!.Value);
job = (await JobService.GetByIdAsync(Id!.Value))!;
addedToOrderList = true;
}
finally
{
addingToOrderList = false;
}
}
private async Task PrintReport()
{
var filename = $"CutList - {job.Name} - {DateTime.Now:yyyy-MM-dd}";
await JS.InvokeVoidAsync("printWithTitle", filename);
}
private void ShowAddCustomStock()
@@ -911,6 +1291,8 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false;
editingStock = null;
packResult = null;
summary = null;
}
private async Task SaveCustomStockAsync()
@@ -956,12 +1338,16 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showCustomStockForm = false;
editingStock = null;
packResult = null;
summary = null;
}
private async Task DeleteStock(JobStock stock)
{
await JobService.DeleteStockAsync(stock.Id);
job = (await JobService.GetByIdAsync(Id!.Value))!;
packResult = null;
summary = null;
}
// Import modal methods
@@ -1048,6 +1434,8 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
}
catch (Exception ex)
{

View File

@@ -63,7 +63,6 @@ else
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
<td>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<a href="jobs/@job.Id/results" class="btn btn-sm btn-success" title="Optimize"><i class="bi bi-scissors"></i></a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)" title="Copy"><i class="bi bi-copy"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)" title="Delete"><i class="bi bi-trash"></i></button>
</td>

View File

@@ -1,344 +0,0 @@
@page "/jobs/{Id:int}/results"
@inject JobService JobService
@inject CutListPackingService PackingService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@inject PurchaseItemService PurchaseItemService
@inject StockItemService StockItemService
@using CutList.Core
@using CutList.Core.Nesting
@using CutList.Core.Formatting
<PageTitle>Results - @(job?.DisplayName ?? "Job")</PageTitle>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (job == null)
{
<div class="alert alert-danger">Job not found.</div>
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1>
@job.DisplayName
@if (job.IsLocked)
{
<i class="bi bi-lock-fill text-warning ms-2" title="Job locked — materials ordered"></i>
}
</h1>
@if (!string.IsNullOrWhiteSpace(job.Customer))
{
<p class="text-muted mb-0">Customer: @job.Customer</p>
}
</div>
<div>
<a href="jobs/@Id" class="btn btn-outline-secondary me-2">Edit Job</a>
<button class="btn btn-primary" @onclick="PrintReport">Print Report</button>
</div>
</div>
@if (!CanOptimize)
{
<div class="alert alert-warning">
<h4>Cannot Optimize</h4>
<ul class="mb-0">
@if (job.Parts.Count == 0)
{
<li>No parts defined. <a href="jobs/@Id">Add parts to the job</a>.</li>
}
@if (job.CuttingToolId == null)
{
<li>No cutting tool selected. <a href="jobs/@Id">Select a cutting tool</a>.</li>
}
</ul>
</div>
}
else if (packResult != null)
{
@if (summary!.TotalItemsNotPlaced > 0)
{
<div class="alert alert-warning">
<h5>Items Not Placed</h5>
<p>Some items could not be placed. This usually means no stock lengths are configured for the material, or parts are too long.</p>
</div>
}
<!-- Overall Summary Cards -->
<div class="row mb-4 print-summary">
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@(summary.TotalInStockBins + summary.TotalToBePurchasedBins)</h2>
<p class="card-text text-muted">Total Stock Bars</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.TotalPieces</h2>
<p class="card-text text-muted">Total Pieces</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@ArchUnits.FormatFromInches(summary.TotalWaste)</h2>
<p class="card-text text-muted">Total Waste</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h2 class="card-title mb-0">@summary.Efficiency.ToString("F1")%</h2>
<p class="card-text text-muted">Efficiency</p>
</div>
</div>
</div>
</div>
<!-- Stock Summary -->
<div class="row mb-4 print-stock-summary">
<div class="col-md-6 mb-3">
<div class="card border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0">In Stock</h5>
</div>
<div class="card-body">
<h3>@summary.TotalInStockBins bars</h3>
<p class="text-muted mb-0">Ready to cut from existing inventory</p>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card border-warning">
<div class="card-header bg-warning">
<h5 class="mb-0">To Be Purchased</h5>
</div>
<div class="card-body">
<h3>@summary.TotalToBePurchasedBins bars</h3>
@if (summary.TotalToBePurchasedBins > 0)
{
@if (addedToOrderList)
{
<div class="alert alert-success mb-0 mt-2 py-2">
Added to order list. <a href="orders">View Orders</a>
</div>
}
else
{
<button class="btn btn-warning btn-sm mt-2" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus"></i> Add to Order List
</button>
}
}
else
{
<p class="text-muted mb-0">Need to order from supplier</p>
}
</div>
</div>
</div>
</div>
<!-- Results by Material -->
@foreach (var materialResult in packResult.MaterialResults)
{
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
<div class="card mb-4">
<div class="card-header">
<h4 class="mb-0">@materialResult.Material.DisplayName</h4>
</div>
<div class="card-body">
<!-- Material Summary -->
<div class="row mb-3">
<div class="col-md-2 col-4">
<strong>@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins)</strong> bars
</div>
<div class="col-md-2 col-4">
<strong>@materialSummary.TotalPieces</strong> pieces
</div>
<div class="col-md-2 col-4">
<strong>@materialSummary.Efficiency.ToString("F1")%</strong> efficiency
</div>
<div class="col-md-3 col-6">
<span class="text-success">@materialSummary.InStockBins in stock</span>
</div>
<div class="col-md-3 col-6">
<span class="text-warning">@materialSummary.ToBePurchasedBins to purchase</span>
</div>
</div>
@if (materialResult.PackResult.ItemsNotUsed.Count > 0)
{
<div class="alert alert-danger">
<strong>@materialResult.PackResult.ItemsNotUsed.Count items not placed</strong> -
No stock lengths available or parts too long.
</div>
}
@if (materialResult.InStockBins.Count > 0)
{
<h5 class="text-success mt-3">In Stock (@materialResult.InStockBins.Count bars)</h5>
@RenderBinList(materialResult.InStockBins)
}
@if (materialResult.ToBePurchasedBins.Count > 0)
{
<h5 class="text-warning mt-3">To Be Purchased (@materialResult.ToBePurchasedBins.Count bars)</h5>
@RenderBinList(materialResult.ToBePurchasedBins)
<!-- Purchase Summary -->
<div class="mt-3 p-3 bg-light rounded">
<strong>Order Summary:</strong>
<ul class="mb-0 mt-2">
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
{
<li>@group.Count() x @ArchUnits.FormatFromInches(group.Key)</li>
}
</ul>
</div>
}
</div>
</div>
}
}
}
@code {
[Parameter]
public int Id { get; set; }
private Job? job;
private MultiMaterialPackResult? packResult;
private MultiMaterialPackingSummary? summary;
private bool loading = true;
private bool addingToOrderList;
private bool addedToOrderList;
private bool CanOptimize => job != null &&
job.Parts.Count > 0 &&
job.CuttingToolId != null;
protected override async Task OnInitializedAsync()
{
job = await JobService.GetByIdAsync(Id);
if (job != null && CanOptimize)
{
var kerf = job.CuttingTool?.KerfInches ?? 0.125m;
// Pass job stock if configured, otherwise packing service uses all available stock
packResult = await PackingService.PackAsync(job.Parts, kerf, job.Stock.Count > 0 ? job.Stock : null);
summary = PackingService.GetSummary(packResult);
addedToOrderList = job.IsLocked;
}
loading = false;
}
private RenderFragment RenderBinList(List<Bin> bins) => __builder =>
{
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th style="width: 80px;">#</th>
<th>Stock Length</th>
<th>Cuts</th>
<th>Waste</th>
</tr>
</thead>
<tbody>
@{ var binNumber = 1; }
@foreach (var bin in bins)
{
<tr>
<td>@binNumber</td>
<td>@ArchUnits.FormatFromInches(bin.Length)</td>
<td>
@foreach (var item in bin.Items)
{
<span class="badge bg-primary me-1">
@(string.IsNullOrWhiteSpace(item.Name) ? ArchUnits.FormatFromInches(item.Length) : $"{item.Name} ({ArchUnits.FormatFromInches(item.Length)})")
</span>
}
</td>
<td>@ArchUnits.FormatFromInches(bin.RemainingLength)</td>
</tr>
binNumber++;
}
</tbody>
</table>
</div>
};
private async Task AddToOrderList()
{
addingToOrderList = true;
try
{
var purchaseItems = new List<PurchaseItem>();
var stockItems = await StockItemService.GetAllAsync();
foreach (var materialResult in packResult!.MaterialResults)
{
if (materialResult.ToBePurchasedBins.Count == 0) continue;
var materialId = materialResult.Material.Id;
// Group bins by length to consolidate quantities
foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length))
{
var lengthInches = (decimal)group.Key;
var quantity = group.Count();
// Find the matching stock item
var stockItem = stockItems.FirstOrDefault(s =>
s.MaterialId == materialId && s.LengthInches == lengthInches);
if (stockItem != null)
{
purchaseItems.Add(new PurchaseItem
{
StockItemId = stockItem.Id,
Quantity = quantity,
JobId = Id,
Status = PurchaseItemStatus.Pending
});
}
}
}
if (purchaseItems.Count > 0)
{
await PurchaseItemService.CreateBulkAsync(purchaseItems);
}
await JobService.LockAsync(Id);
job = await JobService.GetByIdAsync(Id);
addedToOrderList = true;
}
finally
{
addingToOrderList = false;
}
}
private async Task PrintReport()
{
var filename = $"CutList - {job!.Name} - {DateTime.Now:yyyy-MM-dd}";
await JS.InvokeVoidAsync("printWithTitle", filename);
}
}

View File

@@ -234,6 +234,8 @@ public class ApplicationDbContext : DbContext
entity.Property(e => e.Customer).HasMaxLength(100);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.Property(e => e.OptimizationResultJson).HasColumnType("nvarchar(max)");
entity.HasIndex(e => e.JobNumber).IsUnique();
entity.HasOne(e => e.CuttingTool)

View File

@@ -11,6 +11,8 @@ public class Job
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public DateTime? LockedAt { get; set; }
public string? OptimizationResultJson { get; set; }
public DateTime? OptimizedAt { get; set; }
public bool IsLocked => LockedAt.HasValue;

View File

@@ -0,0 +1,905 @@
// <auto-generated />
using System;
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CutList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260209122312_AddJobOptimizationResult")]
partial class AddJobOptimizationResult
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDefault")
.HasColumnType("bit");
b.Property<decimal>("KerfInches")
.HasPrecision(6, 4)
.HasColumnType("decimal(6,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.ToTable("CuttingTools");
b.HasData(
new
{
Id = 1,
IsActive = true,
IsDefault = true,
KerfInches = 0.0625m,
Name = "Bandsaw"
},
new
{
Id = 2,
IsActive = true,
IsDefault = false,
KerfInches = 0.125m,
Name = "Chop Saw"
},
new
{
Id = 3,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Cold Cut Saw"
},
new
{
Id = 4,
IsActive = true,
IsDefault = false,
KerfInches = 0.0625m,
Name = "Hacksaw"
});
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Customer")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("CuttingToolId")
.HasColumnType("int");
b.Property<string>("JobNumber")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("LockedAt")
.HasColumnType("datetime2");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OptimizationResultJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("OptimizedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("CuttingToolId");
b.HasIndex("JobNumber")
.IsUnique();
b.ToTable("Jobs");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("JobId")
.HasColumnType("int");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("MaterialId");
b.ToTable("JobParts");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsCustomLength")
.HasColumnType("bit");
b.Property<int>("JobId")
.HasColumnType("int");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<int>("Priority")
.HasColumnType("int");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int?>("StockItemId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("MaterialId");
b.HasIndex("StockItemId");
b.ToTable("JobStocks");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<string>("Description")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("Grade")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Shape")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Size")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.ToTable("Materials");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DimensionType")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("nvarchar(21)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable("MaterialDimensions");
b.HasDiscriminator<string>("DimensionType").HasValue("MaterialDimensions");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("PurchaseItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<decimal>("LengthInches")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<int>("MaterialId")
.HasColumnType("int");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("QuantityOnHand")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MaterialId", "LengthInches")
.IsUnique();
b.ToTable("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<int?>("JobId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<int?>("SupplierId")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<decimal?>("UnitPrice")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.HasKey("Id");
b.HasIndex("JobId");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId");
b.ToTable("StockTransactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContactInfo")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("GETUTCDATE()");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Suppliers");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("PartNumber")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Price")
.HasPrecision(10, 2)
.HasColumnType("decimal(10,2)");
b.Property<int>("StockItemId")
.HasColumnType("int");
b.Property<string>("SupplierDescription")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<int>("SupplierId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("StockItemId");
b.HasIndex("SupplierId", "StockItemId")
.IsUnique();
b.ToTable("SupplierOfferings");
});
modelBuilder.Entity("CutList.Web.Data.Entities.AngleDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Leg1")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Leg2")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Thickness")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Thickness");
b.HasIndex("Leg1");
b.HasDiscriminator().HasValue("Angle");
});
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Flange")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.HasDiscriminator().HasValue("Channel");
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Thickness");
b.Property<decimal>("Width")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Width");
b.HasIndex("Width");
b.HasDiscriminator().HasValue("FlatBar");
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.HasDiscriminator().HasValue("IBeam");
});
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("NominalSize")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<string>("Schedule")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal?>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
b.HasIndex("NominalSize");
b.HasDiscriminator().HasValue("Pipe");
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
b.Property<decimal>("Width")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Width");
b.HasIndex("Width");
b.HasDiscriminator().HasValue("RectangularTube");
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Diameter")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Diameter");
b.HasDiscriminator().HasValue("RoundBar");
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("OuterDiameter")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
b.HasIndex("OuterDiameter");
b.HasDiscriminator().HasValue("RoundTube");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Size");
b.HasIndex("Size");
b.HasDiscriminator().HasValue("SquareBar");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Size");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
b.HasIndex("Size");
b.HasDiscriminator().HasValue("SquareTube");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.HasOne("CutList.Web.Data.Entities.CuttingTool", "CuttingTool")
.WithMany("Jobs")
.HasForeignKey("CuttingToolId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CuttingTool");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobPart", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany("Parts")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("JobParts")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Job");
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.JobStock", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany("Stock")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany()
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany()
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("Material");
b.Navigation("StockItem");
});
modelBuilder.Entity("CutList.Web.Data.Entities.MaterialDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithOne("Dimensions")
.HasForeignKey("CutList.Web.Data.Entities.MaterialDimensions", "MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.PurchaseItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany()
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.HasOne("CutList.Web.Data.Entities.Material", "Material")
.WithMany("StockItems")
.HasForeignKey("MaterialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Material");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockTransaction", b =>
{
b.HasOne("CutList.Web.Data.Entities.Job", "Job")
.WithMany()
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("Transactions")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Job");
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SupplierOffering", b =>
{
b.HasOne("CutList.Web.Data.Entities.StockItem", "StockItem")
.WithMany("SupplierOfferings")
.HasForeignKey("StockItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("CutList.Web.Data.Entities.Supplier", "Supplier")
.WithMany("Offerings")
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StockItem");
b.Navigation("Supplier");
});
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Navigation("Jobs");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
{
b.Navigation("Parts");
b.Navigation("Stock");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Material", b =>
{
b.Navigation("Dimensions");
b.Navigation("JobParts");
b.Navigation("StockItems");
});
modelBuilder.Entity("CutList.Web.Data.Entities.StockItem", b =>
{
b.Navigation("SupplierOfferings");
b.Navigation("Transactions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.Supplier", b =>
{
b.Navigation("Offerings");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class AddJobOptimizationResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OptimizationResultJson",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "OptimizedAt",
table: "Jobs",
type: "datetime2",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OptimizationResultJson",
table: "Jobs");
migrationBuilder.DropColumn(
name: "OptimizedAt",
table: "Jobs");
}
}
}

View File

@@ -119,6 +119,12 @@ namespace CutList.Web.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OptimizationResultJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("OptimizedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");

View File

@@ -172,6 +172,18 @@ public class CutListPackingService
return result;
}
public MultiMaterialPackResult? LoadSavedResult(string json)
{
var saved = System.Text.Json.JsonSerializer.Deserialize<SavedOptimizationResult>(json);
return saved?.ToPackResult(_context);
}
public string SerializeResult(MultiMaterialPackResult result)
{
var saved = SavedOptimizationResult.FromPackResult(result);
return System.Text.Json.JsonSerializer.Serialize(saved);
}
public MultiMaterialPackingSummary GetSummary(MultiMaterialPackResult result)
{
var summary = new MultiMaterialPackingSummary();
@@ -275,3 +287,109 @@ public class MaterialPackingSummary
public double Efficiency { get; set; }
public int ItemsNotPlaced { get; set; }
}
// --- Serialization DTOs for persisting optimization results ---
public class SavedBinItem
{
public string Name { get; set; } = string.Empty;
public double Length { get; set; }
}
public class SavedBin
{
public double Length { get; set; }
public double Spacing { get; set; }
public List<SavedBinItem> Items { get; set; } = new();
}
public class SavedMaterialResult
{
public int MaterialId { get; set; }
public string MaterialDisplayName { get; set; } = string.Empty;
public List<SavedBin> InStockBins { get; set; } = new();
public List<SavedBin> ToBePurchasedBins { get; set; } = new();
public List<SavedBinItem> ItemsNotPlaced { get; set; } = new();
}
public class SavedOptimizationResult
{
public DateTime OptimizedAt { get; set; }
public List<SavedMaterialResult> MaterialResults { get; set; } = new();
public static SavedOptimizationResult FromPackResult(MultiMaterialPackResult result)
{
var saved = new SavedOptimizationResult
{
OptimizedAt = DateTime.UtcNow
};
foreach (var mr in result.MaterialResults)
{
var savedMr = new SavedMaterialResult
{
MaterialId = mr.Material.Id,
MaterialDisplayName = mr.Material.DisplayName
};
savedMr.InStockBins = mr.InStockBins.Select(ToBinDto).ToList();
savedMr.ToBePurchasedBins = mr.ToBePurchasedBins.Select(ToBinDto).ToList();
savedMr.ItemsNotPlaced = mr.PackResult.ItemsNotUsed
.Select(i => new SavedBinItem { Name = i.Name, Length = i.Length })
.ToList();
saved.MaterialResults.Add(savedMr);
}
return saved;
}
public MultiMaterialPackResult ToPackResult(ApplicationDbContext context)
{
var result = new MultiMaterialPackResult();
foreach (var savedMr in MaterialResults)
{
var material = context.Materials.Find(savedMr.MaterialId);
if (material == null) continue;
var packResult = new PackResult();
var inStockBins = savedMr.InStockBins.Select(FromBinDto).ToList();
var toBePurchasedBins = savedMr.ToBePurchasedBins.Select(FromBinDto).ToList();
// Add all bins to PackResult so summary calculations work
foreach (var bin in inStockBins) packResult.AddBin(bin);
foreach (var bin in toBePurchasedBins) packResult.AddBin(bin);
foreach (var item in savedMr.ItemsNotPlaced)
packResult.AddItemNotUsed(new BinItem(item.Name, item.Length));
result.MaterialResults.Add(new MaterialPackResult
{
Material = material,
PackResult = packResult,
InStockBins = inStockBins,
ToBePurchasedBins = toBePurchasedBins
});
}
return result;
}
private static SavedBin ToBinDto(Bin bin)
{
return new SavedBin
{
Length = bin.Length,
Spacing = bin.Spacing,
Items = bin.Items.Select(i => new SavedBinItem { Name = i.Name, Length = i.Length }).ToList()
};
}
private static Bin FromBinDto(SavedBin dto)
{
var bin = new Bin(dto.Length) { Spacing = dto.Spacing };
foreach (var item in dto.Items)
bin.AddItem(new BinItem(item.Name, item.Length));
return bin;
}
}

View File

@@ -72,6 +72,8 @@ public class JobService
public async Task UpdateAsync(Job job)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
_context.Jobs.Update(job);
await _context.SaveChangesAsync();
}
@@ -161,6 +163,29 @@ public class JobService
return duplicate;
}
// Optimization result persistence
public async Task SaveOptimizationResultAsync(int jobId, string resultJson, DateTime optimizedAt)
{
var job = await _context.Jobs.FindAsync(jobId);
if (job != null)
{
job.OptimizationResultJson = resultJson;
job.OptimizedAt = optimizedAt;
await _context.SaveChangesAsync();
}
}
public async Task ClearOptimizationResultAsync(int jobId)
{
var job = await _context.Jobs.FindAsync(jobId);
if (job != null && job.OptimizationResultJson != null)
{
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
// Parts management
public async Task<JobPart> AddPartAsync(JobPart part)
{
@@ -172,11 +197,13 @@ public class JobService
_context.JobParts.Add(part);
await _context.SaveChangesAsync();
// Update job timestamp
// Update job timestamp and clear stale results
var job = await _context.Jobs.FindAsync(part.JobId);
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
@@ -192,6 +219,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -209,6 +238,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -229,6 +260,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
@@ -244,6 +277,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -261,6 +296,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,147 @@
<#
Deploy CutList.Web as a Windows Service
Examples:
# Run from repository root:
powershell -ExecutionPolicy Bypass -File scripts/Deploy-CutListWeb.ps1 -ServiceName CutListWeb -InstallDir C:\Services\CutListWeb -Urls "http://*:5270" -OpenFirewall
# Run from scripts directory:
powershell -ExecutionPolicy Bypass -File Deploy-CutListWeb.ps1 -ServiceName CutListWeb -InstallDir C:\Services\CutListWeb -Urls "http://*:5270" -OpenFirewall
Requires: dotnet SDK/runtime installed and administrative privileges.
#>
Param(
[string]$ServiceName = "CutListWeb",
[string]$PublishConfiguration = "Release",
[string]$InstallDir = "C:\Services\CutListWeb",
[string]$Urls = "http://*:5270",
[switch]$OpenFirewall,
[int]$PublishTimeoutSeconds = 180,
[int]$ServiceStopTimeoutSeconds = 30,
[int]$ServiceStartTimeoutSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Detect repository root (parent of scripts directory or current directory if already at root)
$ScriptDir = Split-Path -Parent $PSCommandPath
$RepoRoot = if ((Split-Path -Leaf $ScriptDir) -eq 'scripts') {
Split-Path -Parent $ScriptDir
} else {
$ScriptDir
}
Write-Host "Repository root: $RepoRoot"
$ProjectPath = Join-Path $RepoRoot 'CutList.Web\CutList.Web.csproj'
if (-not (Test-Path -LiteralPath $ProjectPath)) {
throw "Project not found at: $ProjectPath"
}
function Ensure-Dir($path) {
if (-not (Test-Path -LiteralPath $path)) {
New-Item -ItemType Directory -Path $path | Out-Null
}
}
function Stop-And-DeleteService($name) {
$svc = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -ne $svc) {
if ($svc.Status -ne 'Stopped') {
Write-Host "Stopping service '$name'..."
Stop-Service -Name $name -Force -ErrorAction SilentlyContinue
try { $svc.WaitForStatus('Stopped',[TimeSpan]::FromSeconds($ServiceStopTimeoutSeconds)) | Out-Null } catch {}
# If still running, kill by PID
$q = & sc.exe queryex $name 2>$null
$pidLine = $q | Where-Object { $_ -match 'PID' }
if ($pidLine -and ($pidLine -match '(\d+)$')) {
$procId = [int]$Matches[1]
if ($procId -gt 0) {
try { Write-Host "Killing service process PID=$procId ..."; Stop-Process -Id $procId -Force } catch {}
}
}
}
Write-Host "Deleting service '$name'..."
sc.exe delete $name | Out-Null
Start-Sleep -Seconds 1
}
}
function Publish-App() {
Write-Host "Publishing CutList.Web to $InstallDir ..."
Ensure-Dir $InstallDir
# Run dotnet publish directly - output will be visible
& dotnet publish $ProjectPath -c $PublishConfiguration -o $InstallDir
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
}
function Stop-ExeLocks($path) {
$procs = Get-Process -ErrorAction SilentlyContinue | Where-Object {
$_.Path -and ($_.Path -ieq $path)
}
foreach ($p in $procs) {
try { Write-Host "Killing process $($p.Id) $($p.ProcessName) ..."; Stop-Process -Id $p.Id -Force } catch {}
}
# Wait until unlocked
for ($i=0; $i -lt 50; $i++) {
$still = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and ($_.Path -ieq $path) }
if (-not $still) { break }
Start-Sleep -Milliseconds 200
}
}
function Create-Service($name, $bin, $urls) {
$binPath = '"' + $bin + '" --urls ' + $urls
Write-Host "Creating service '$name' with binPath: $binPath"
# Note: space after '=' is required for sc.exe syntax
sc.exe create $name binPath= "$binPath" start= auto DisplayName= "$name" | Out-Null
# Set recovery to restart on failure
sc.exe failure $name reset= 86400 actions= restart/60000/restart/60000/restart/60000 | Out-Null
}
function Start-ServiceSafe($name) {
Write-Host "Starting service '$name'..."
Start-Service -Name $name
(Get-Service -Name $name).WaitForStatus('Running',[TimeSpan]::FromSeconds($ServiceStartTimeoutSeconds)) | Out-Null
sc.exe query $name | Write-Host
}
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet SDK/Runtime not found in PATH. Please install .NET 8+ or add it to PATH."
}
Stop-And-DeleteService -name $ServiceName
Stop-ExeLocks -path (Join-Path $InstallDir 'CutList.Web.exe')
try { Remove-Item -LiteralPath (Join-Path $InstallDir 'CutList.Web.exe') -Force -ErrorAction SilentlyContinue } catch {}
Publish-App
$exe = Join-Path $InstallDir 'CutList.Web.exe'
if (-not (Test-Path -LiteralPath $exe)) {
throw "Expected published executable not found: $exe"
}
Create-Service -name $ServiceName -bin $exe -urls $Urls
if ($OpenFirewall) {
$port = ($Urls -split ':')[-1]
if ($port -match '^(\d+)$') {
$ruleName = "$ServiceName HTTP $port"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($null -eq $existingRule) {
Write-Host "Creating firewall rule for TCP port $port ..."
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow | Out-Null
} else {
Write-Host "Firewall rule '$ruleName' already exists, skipping creation."
}
}
}
Start-ServiceSafe -name $ServiceName
Write-Host "Deployment complete. Service '$ServiceName' is running."