Compare commits
6 Commits
c23c92e852
...
2fdf006a8e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fdf006a8e | |||
| eee38a8473 | |||
| 59f86c8e79 | |||
| 891b214b29 | |||
| c5f366a3ef | |||
| 8926d44969 |
15
CLAUDE.md
15
CLAUDE.md
@@ -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 |
|
||||
|
||||
@@ -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--;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
905
CutList.Web/Migrations/20260209122312_AddJobOptimizationResult.Designer.cs
generated
Normal file
905
CutList.Web/Migrations/20260209122312_AddJobOptimizationResult.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
147
scripts/Deploy-CutListWeb.ps1
Normal file
147
scripts/Deploy-CutListWeb.ps1
Normal 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."
|
||||
Reference in New Issue
Block a user