Compare commits

..

27 Commits

Author SHA1 Message Date
aj 7d3c92226c refactor: replace generic catalog DTOs with shape-typed DTOs for type safety
Replace the single CatalogMaterialDto + CatalogDimensionsDto (bag of nullable
fields) with per-shape DTOs that have strongly-typed dimension properties.
Catalog JSON now groups materials by shape key instead of a flat array.
Delete the old SeedController/SeedDataDtos (superseded by CatalogService).
Scraper updated to emit the new grouped format, resume by default, and
save items incrementally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:48:35 -05:00
aj c31769a746 chore: standardize deploy script to match template
Remove unused $PublishTimeoutSeconds param. Add sc.exe description for
service metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:14:29 -05:00
aj f04bf02c42 feat: Migrate MaterialDimensions from TPH to TPC and add Alro catalog seeding
Switch MaterialDimensions inheritance from TPH (single table with discriminator)
to TPC (table per concrete type) with individual tables per shape. Add Swagger
for dev API exploration, expand SeedController with export/import endpoints and
Alro catalog JSON dataset, and include Python scraper for Alro catalog PDFs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:23:01 -05:00
aj dac2833dd1 chore: Remove ExportData script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:01:22 -05:00
aj a226a1f652 feat: Redesign job editor with multi-row parts and unified cut list results
- Part form now supports adding multiple parts at once via a table with
  add/remove row controls; edit mode stays single-row
- Shape and size dropdowns lock when editing an existing part
- Results tab replaces split in-stock/purchase cards with a unified table
  per material showing source badges (Stock/Purchase) for each bar
- New Purchase List card summarizes materials to order with quantities
- Print styles use repeating thead headers per material for multi-page
  cut lists; large cards can now break across pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:01:01 -05:00
aj 5000021193 feat: Add catalog import/export API endpoints
Replace the standalone ExportData console app and hardcoded SeedController
with generic GET /api/catalog/export and POST /api/catalog/import endpoints.
Import uses upsert semantics with per-item error handling, preserving
existing inventory quantities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:09:53 -05:00
aj 02e936febb feat: Add database export script and O'Neal Steel catalog dataset
Export tool queries all active materials, stock items, suppliers, and
offerings from the database and writes a clean JSON file for version
control. Includes 616 materials and 810 stock items with part numbers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:34:35 -05:00
aj e13f876da6 feat: Add MCP tools for job management and optimization
Add JobTools class exposing MCP tools for:
- Job CRUD (list, get, create, update, delete)
- Part management (add single, batch add, delete)
- Stock assignments (add, delete)
- Bin packing optimization with kerf override
- Cutting tool listing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:20:59 -05:00
aj 1f3eb67eb7 feat: Add job and cutting tool API client methods
Add HTTP client methods for job CRUD, parts, stock, packing, and
cutting tool endpoints. Includes response DTOs for all job-related
API responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 22:20:53 -05:00
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj c23c92e852 docs: Update CLAUDE.md with CutList.Web entities, services, and pages
Comprehensive rewrite covering all three projects, entity definitions,
service APIs, page routes, shared components, and key patterns including
purchase flow and job locking conventions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:03:33 -05:00
aj 2586f99c63 feat: Add purchase order flow with Orders pages
Add "Add to Order List" button on Results page that creates PurchaseItems
from optimization results and locks the job. Add Orders Index page with
tabbed view (Pending/Ordered/All), supplier assignment, status
transitions, and MaterialFilter. Add manual order item creation page.
Add Orders link to navigation menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:03:17 -05:00
aj 5f4e36c688 feat: Add job locking UI to Edit and Index pages
Locked jobs show a warning banner with unlock button on the Edit page.
All form fields, part/stock add/edit/delete buttons are disabled via
fieldset when locked. Jobs Index shows a lock icon badge next to locked
job numbers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:02:59 -05:00
aj ed705625e9 feat: Add PurchaseItem entity and job locking data layer
Add PurchaseItem entity with status tracking (Pending/Ordered/Received),
supplier and job relationships. Add LockedAt timestamp to Job entity for
controlling editability after materials are ordered. Includes
PurchaseItemService (CRUD + bulk create), JobService Lock/Unlock methods,
EF Core migrations, and DI registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 23:02:40 -05:00
aj 1ccdeb6817 fix: Print report showing blank preview and wasted first page
The print CSS was hiding all .card elements, which contained the
actual results content. Replaced with compact print-friendly styles:
summary cards display as an inline row, redundant stock summary is
hidden, and spacing is tightened throughout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:37:13 -05:00
aj 69b282aaf3 feat: Add material filtering to Materials and Stock pages
Integrate MaterialFilter component for filtering by shape,
type, grade, and free-text search. Pagination now reflects
filtered results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:37:04 -05:00
aj f932e8ba13 feat: Add reusable MaterialFilter component
Provides shape, type, grade dropdowns and text search
for filtering material-based list pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:36:58 -05:00
aj 141176cc5d feat: Replace text buttons with icons across all pages
Use Bootstrap Icons for Edit, Delete, Copy, and Optimize actions
to reduce table column widths and improve visual consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:36:51 -05:00
aj 3fd354aff0 feat: Add Bootstrap Icons CDN for icon-based UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:36:44 -05:00
aj b603a4b3e7 chore: Update connection string to SQL Server Express
Switch from localdb to SQLEXPRESS instance with TrustServerCertificate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:46:20 -05:00
aj 5468b2748d feat: Add pagination to all list pages
Integrate Pager component into Jobs, Materials, Stock, Suppliers,
and Tools index pages with a page size of 25. Handles page
adjustment on delete when the last page becomes empty.

Also removes the Description column from the Materials table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:46:12 -05:00
aj 8ed10939d4 feat: Add reusable Pager component for list pagination
Shared Blazor component with page windowing, ellipsis for large
page counts, and "Showing X-Y of Z" summary text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:45:41 -05:00
54 changed files with 27491 additions and 701 deletions
+187 -59
View File
@@ -1,13 +1,22 @@
# CLAUDE.md
> **IMPORTANT**: Always keep this document updated when functionality changes, entities are added/modified, new pages or services are created, or architectural patterns evolve.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
CutList is a Windows Forms 1D bin packing optimization application that helps users optimize material cutting. It calculates efficient bin packing solutions to minimize waste when cutting stock materials into required parts.
CutList is a 1D bin packing optimization application that helps users optimize material cutting. It calculates efficient bin packing solutions to minimize waste when cutting stock materials into required parts.
**Framework**: .NET 8.0 (Windows Forms)
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization)
The solution contains three projects:
| Project | Framework | Purpose |
|---------|-----------|---------|
| **CutList** | .NET 8.0 Windows Forms | Original desktop UI (MVP pattern) |
| **CutList.Core** | .NET 8.0 Class Library | Domain models and packing algorithms (platform-agnostic) |
| **CutList.Web** | .NET 8.0 Blazor Server | Web-based UI with EF Core + SQL Server |
**Key Dependencies**: Math-Expression-Evaluator (input parsing), Newtonsoft.Json (serialization), Entity Framework Core (data access), Bootstrap 5 + Bootstrap Icons (UI)
## Build Commands
@@ -15,12 +24,18 @@ CutList is a Windows Forms 1D bin packing optimization application that helps us
# Build entire solution
dotnet build CutList.sln
# Build specific project
# Build specific projects
dotnet build CutList/CutList.csproj
dotnet build CutList.Core/CutList.Core.csproj
dotnet build CutList.Web/CutList.Web.csproj
# Run the application
dotnet run --project CutList/CutList.csproj
# Run applications
dotnet run --project CutList/CutList.csproj # WinForms
dotnet run --project CutList.Web/CutList.Web.csproj # Blazor
# EF Core migrations (always apply immediately after creating)
dotnet ef migrations add <Name> --project CutList.Web
dotnet ef database update --project CutList.Web
# Clean build
dotnet clean CutList.sln
@@ -28,69 +43,182 @@ dotnet clean CutList.sln
## Architecture
### Project Structure
### CutList.Core — Domain & Algorithms
- **CutList/** - Main Windows Forms application (UI layer)
- **CutList.Core/** - Core library with domain models and packing algorithms (shareable, platform-agnostic)
### Key Patterns
**MVP (Model-View-Presenter)**
- `MainForm` implements `IMainView` (pure UI, no business logic)
- `MainFormPresenter` orchestrates all business logic
- `Document` contains application state
**Result Pattern**
- `Result<T>` in Common/Result.cs for standardized error handling
- Use `Result<T>.Success(value)` and `Result<T>.Failure(error)` instead of exceptions for expected errors
**Factory Pattern**
- `IEngineFactory` creates packing engines
- Allows swapping algorithm implementations
### Data Flow
1. User enters parts/bins in MainForm
2. MainFormPresenter calls CutListService.Pack()
3. CutListService converts input models to core models (MultiBin, BinItem)
4. MultiBinEngine coordinates packing across bin types
5. AdvancedFitEngine performs 1D bin packing (first-fit decreasing with optimization)
6. Results displayed in ResultsForm
### Key Services
- **CutListService**: Bridges UI models to core packing algorithms
- **DocumentService**: JSON file persistence
### Domain Models (CutList.Core)
- **BinItem**: Item to be packed (with label, length)
**Key Domain Models**:
- **BinItem**: Item to be packed (label, length)
- **MultiBin**: Stock bin type with length, quantity (-1 = unlimited), priority
- **Bin**: Packed bin containing items
- **Bin**: Packed bin containing items, tracks remaining length
- **Tool**: Cutting tool with kerf/blade width
### Unit Handling
**Packing Engine**:
- `MultiBinEngine` coordinates packing across bin types
- `AdvancedFitEngine` performs 1D bin packing (first-fit decreasing with optimization)
- `PackingStrategy.AdvancedFit` is the standard strategy
- **ArchUnits**: Converts feet/inches/fractions to decimals (accepts "12'", "6\"", "12 1/2\"", etc.)
- **FormatHelper**: Converts decimals to mixed fractions for display
**Unit Handling**:
- `ArchUnits` — Converts feet/inches/fractions to decimal inches (accepts "12'", "6\"", "12 1/2\"", etc.)
- `FormatHelper` — Converts decimals to mixed fractions for display
- Internal calculations use inches; format on display
## Important Conventions
**Patterns**:
- `Result<T>` for standardized error handling (Success/Failure instead of exceptions)
- `IEngineFactory` for swappable algorithm implementations
- Lower priority number = used first in bin selection
- **Nullable reference types enabled** - handle nulls explicitly
- **Collections are encapsulated** - use AsReadOnly(), access via Add* methods
- **Validation in domain models** - constructors and properties validate inputs
- **Parameterless constructors** on Tool/MultiBin are for JSON serialization only
- **Spacing property** on engines handles blade/kerf width
- **Priority system**: Lower priority bins are used first
### CutList (WinForms) — Desktop UI
- MVP pattern: `MainForm` implements `IMainView`, `MainFormPresenter` orchestrates logic
- `Document` holds application state
- `CutListService` bridges UI models to core packing algorithms
- `DocumentService` handles JSON file persistence
### CutList.Web (Blazor Server) — Web UI
**Database**: SQL Server via Entity Framework Core (connection string: `DefaultConnection`)
**Service Registration** (Program.cs): All services registered as Scoped — MaterialService, SupplierService, StockItemService, JobService, CutListPackingService, ReportService, PurchaseItemService
## CutList.Web Entities
### Material
- `Shape` (MaterialShape enum): Round Bar, Round Tube, Flat Bar, Square Bar, Square Tube, Rectangular Tube, Angle, Channel, I-Beam, Pipe
- `Type` (MaterialType enum): Steel, Aluminum, Stainless, Brass, Copper
- `Grade` (string?), `Size` (string), `Description` (string?), `IsActive` (bool), `SortOrder` (int)
- **DisplayName**: "{Shape} - {Size}"
- **Relationships**: `Dimensions` (1:1 MaterialDimensions), `StockItems` (1:many), `JobParts` (1:many)
### MaterialDimensions (TPC Inheritance)
Abstract base with TPC (Table Per Concrete type) mapping — each shape gets its own standalone table (`DimAngle`, `DimChannel`, `DimFlatBar`, `DimIBeam`, `DimPipe`, `DimRectangularTube`, `DimRoundBar`, `DimRoundTube`, `DimSquareBar`, `DimSquareTube`) with no base table. Each table has its own `Id` (shared sequence) and `MaterialId` FK. Each generates its own `SizeString` and `SortOrder`.
### StockItem
- `MaterialId`, `LengthInches` (decimal), `QuantityOnHand` (int), `IsActive`
- **Unique constraint**: (MaterialId, LengthInches)
- **Relationships**: `Material`, `SupplierOfferings` (1:many), `Transactions` (1:many StockTransaction)
### StockTransaction
- `StockItemId`, `Quantity` (signed delta), `Type` (Received/Used/Adjustment/Scrapped/Returned)
- Optional: `JobId`, `SupplierId`, `UnitPrice`
### Supplier
- `Name` (required), `ContactInfo`, `Notes`, `IsActive`
- **Relationships**: `Offerings` (1:many SupplierOffering)
### SupplierOffering
- Links Supplier to StockItem with optional `PartNumber`, `Price`, `Notes`
- **Unique constraint**: (SupplierId, StockItemId)
### CuttingTool
- `Name`, `KerfInches` (decimal), `IsDefault` (bool), `IsActive`
- **Seeded**: Bandsaw (0.0625"), Chop Saw (0.125"), Cold Cut Saw (0.0625"), Hacksaw (0.0625")
### 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
- `JobId`, `MaterialId`, `Name`, `LengthInches` (decimal), `Quantity` (int), `SortOrder`
### JobStock
- `JobId`, `MaterialId`, `StockItemId?`, `LengthInches`, `Quantity` (-1 = unlimited), `IsCustomLength`, `Priority` (lower = used first), `SortOrder`
### PurchaseItem
- `StockItemId`, `SupplierId?`, `JobId?`, `Quantity`, `Status` (Pending/Ordered/Received), `Notes`
## CutList.Web Services
### MaterialService
- CRUD with soft delete, dimension management (`CreateWithDimensionsAsync`, `UpdateWithDimensionsAsync`)
- Search by dimension (e.g., `SearchRoundBarByDiameterAsync`, `SearchAngleByLegAsync`) with tolerance
- `CreateDimensionsForShape(shape)` factory method
### StockItemService
- CRUD with soft delete
- Stock transactions: `AddStockAsync`, `UseStockAsync`, `AdjustStockAsync`, `ScrapStockAsync`
- `GetTransactionHistoryAsync`, `RecalculateQuantityAsync`
- Pricing: `GetAverageCostAsync`, `GetLastPurchasePriceAsync`
### SupplierService
- CRUD for suppliers and offerings
- `GetOfferingsForStockItemAsync` — all supplier options for a stock item
### 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 + 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
- `UpdateStatusAsync(id, status)`, `UpdateSupplierAsync(id, supplierId)`
### ReportService
- `FormatLength(inches)`, `GroupItems(items)` for print report formatting
## CutList.Web Pages
| Route | Page | Purpose |
|-------|------|---------|
| `/` | 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, 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 |
| `/stock/new`, `/stock/{Id}` | Stock/Edit | Stock item form |
| `/orders` | Orders/Index | Tabbed (Pending/Ordered/All), supplier assignment, status transitions |
| `/orders/add` | Orders/Add | Manual purchase item creation |
| `/suppliers` | Suppliers/Index | Supplier list with CRUD |
| `/suppliers/{Id}` | Suppliers/Edit | Supplier + offerings management |
| `/tools` | Tools/Index | Cutting tools CRUD |
## Shared Components
| Component | Purpose |
|-----------|---------|
| `ConfirmDialog` | Modal confirmation for destructive actions (Show/Hide methods, OnConfirm callback) |
| `LengthInput` | Architectural unit input — parses "12'", "6\"", "12 1/2\""; reformats on blur; two-way binding via `Value` or `NullableValue` |
| `Pager` | Pagination with "Showing X-Y of Z", prev/next, smart page window with ellipsis |
| `MaterialFilter` | Reusable filter: Shape, Type, Grade dropdowns + search text; used on Materials, Stock, Orders pages |
## Key Patterns & Conventions
- **Nullable reference types enabled** — handle nulls explicitly
- **Soft deletes** — Materials, Suppliers, StockItems, CuttingTools use `IsActive` flag
- **Job locking** — `LockedAt` timestamp set when materials ordered; Edit page disables all modification via `<fieldset disabled>`, hides add/edit/delete buttons; Unlock button to re-enable editing
- **Pagination** — All list pages use `Pager` with `pageSize = 25`
- **ConfirmDialog** — All destructive actions use the shared `ConfirmDialog` component
- **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
- **Priority system** — Lower priority bins used first in packing algorithm
## Key Files
| File | Purpose |
|------|---------|
| `Presenters/MainFormPresenter.cs` | Main business logic orchestrator |
| `Services/CutListService.cs` | Packing algorithm interface |
| `CutList.Core/Nesting/AdvancedFitEngine.cs` | Core packing algorithm |
| `CutList.Core/Nesting/MultiBinEngine.cs` | Multi-bin orchestration |
| `CutList.Core/ArchUnits.cs` | Unit conversion |
| `CutList.Core/FormatHelper.cs` | Output formatting |
| `CutList.Core/Nesting/AdvancedFitEngine.cs` | Core 1D bin packing algorithm |
| `CutList.Core/Nesting/MultiBinEngine.cs` | Multi-bin type orchestration |
| `CutList.Core/ArchUnits.cs` | Architectural unit parsing/conversion |
| `CutList.Core/Formatting/FormatHelper.cs` | Display formatting |
| `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, Results) |
| `CutList/Presenters/MainFormPresenter.cs` | WinForms business logic orchestrator |
+27 -6
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--;
+231 -1
View File
@@ -109,6 +109,109 @@ public class ApiClient
#endregion
#region Jobs
public async Task<List<ApiJobDto>> GetJobsAsync()
{
return await _http.GetFromJsonAsync<List<ApiJobDto>>("api/jobs") ?? [];
}
public async Task<ApiJobDetailDto?> GetJobAsync(int id)
{
return await _http.GetFromJsonAsync<ApiJobDetailDto>($"api/jobs/{id}");
}
public async Task<ApiJobDetailDto?> CreateJobAsync(string? name, string? customer, int? cuttingToolId, string? notes)
{
var response = await _http.PostAsJsonAsync("api/jobs", new { Name = name, Customer = customer, CuttingToolId = cuttingToolId, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiJobDetailDto>();
}
public async Task<ApiJobDetailDto?> UpdateJobAsync(int id, string? name, string? customer, int? cuttingToolId, string? notes)
{
var response = await _http.PutAsJsonAsync($"api/jobs/{id}", new { Name = name, Customer = customer, CuttingToolId = cuttingToolId, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiJobDetailDto>();
}
public async Task DeleteJobAsync(int id)
{
var response = await _http.DeleteAsync($"api/jobs/{id}");
response.EnsureSuccessStatusCode();
}
public async Task<ApiJobPartDto?> AddJobPartAsync(int jobId, int materialId, string name, string length, int quantity)
{
var response = await _http.PostAsJsonAsync($"api/jobs/{jobId}/parts", new { MaterialId = materialId, Name = name, Length = length, Quantity = quantity });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiJobPartDto>();
}
public async Task<ApiJobPartDto?> UpdateJobPartAsync(int jobId, int partId, int? materialId, string? name, string? length, int? quantity)
{
var response = await _http.PutAsJsonAsync($"api/jobs/{jobId}/parts/{partId}", new { MaterialId = materialId, Name = name, Length = length, Quantity = quantity });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiJobPartDto>();
}
public async Task DeleteJobPartAsync(int jobId, int partId)
{
var response = await _http.DeleteAsync($"api/jobs/{jobId}/parts/{partId}");
response.EnsureSuccessStatusCode();
}
public async Task<ApiJobStockDto?> AddJobStockAsync(int jobId, int materialId, int? stockItemId, string length, int quantity, bool isCustomLength, int priority)
{
var response = await _http.PostAsJsonAsync($"api/jobs/{jobId}/stock", new
{
MaterialId = materialId,
StockItemId = stockItemId,
Length = length,
Quantity = quantity,
IsCustomLength = isCustomLength,
Priority = priority
});
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiJobStockDto>();
}
public async Task DeleteJobStockAsync(int jobId, int stockId)
{
var response = await _http.DeleteAsync($"api/jobs/{jobId}/stock/{stockId}");
response.EnsureSuccessStatusCode();
}
public async Task<ApiPackResponseDto?> PackJobAsync(int jobId, decimal? kerfOverride = null)
{
var response = await _http.PostAsJsonAsync($"api/jobs/{jobId}/pack", new { KerfOverride = kerfOverride });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiPackResponseDto>();
}
#endregion
#region Cutting Tools
public async Task<List<ApiCuttingToolDto>> GetCuttingToolsAsync(bool includeInactive = false)
{
return await _http.GetFromJsonAsync<List<ApiCuttingToolDto>>($"api/cutting-tools?includeInactive={includeInactive}") ?? [];
}
public async Task<ApiCuttingToolDto?> GetDefaultCuttingToolAsync()
{
try
{
return await _http.GetFromJsonAsync<ApiCuttingToolDto>("api/cutting-tools/default");
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}
#endregion
#region Offerings
public async Task<List<ApiOfferingDto>> GetOfferingsForSupplierAsync(int supplierId)
@@ -153,7 +256,134 @@ public class ApiConflictException : Exception
public ApiConflictException(string message) : base(message) { }
}
#region API Response DTOs
#region API Response DTOs Jobs & Cutting Tools
public class ApiJobDto
{
public int Id { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string? Name { get; set; }
public string? Customer { get; set; }
public int? CuttingToolId { get; set; }
public string? CuttingToolName { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int PartCount { get; set; }
public int StockCount { get; set; }
}
public class ApiJobDetailDto : ApiJobDto
{
public List<ApiJobPartDto> Parts { get; set; } = new();
public List<ApiJobStockDto> Stock { get; set; } = new();
}
public class ApiJobPartDto
{
public int Id { get; set; }
public int JobId { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int Quantity { get; set; }
public int SortOrder { get; set; }
}
public class ApiJobStockDto
{
public int Id { get; set; }
public int JobId { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public int? StockItemId { get; set; }
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int Quantity { get; set; }
public bool IsCustomLength { get; set; }
public int Priority { get; set; }
public int SortOrder { get; set; }
}
public class ApiCuttingToolDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal KerfInches { get; set; }
public bool IsDefault { get; set; }
public bool IsActive { get; set; }
}
public class ApiPackResponseDto
{
public List<ApiMaterialPackResultDto> Materials { get; set; } = new();
public ApiPackingSummaryDto Summary { get; set; } = new();
}
public class ApiMaterialPackResultDto
{
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public List<ApiPackedBinDto> InStockBins { get; set; } = new();
public List<ApiPackedBinDto> ToBePurchasedBins { get; set; } = new();
public List<ApiPackedItemDto> ItemsNotPlaced { get; set; } = new();
public ApiMaterialPackingSummaryDto Summary { get; set; } = new();
}
public class ApiPackedBinDto
{
public double LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public double UsedInches { get; set; }
public string UsedFormatted { get; set; } = string.Empty;
public double WasteInches { get; set; }
public string WasteFormatted { get; set; } = string.Empty;
public double Efficiency { get; set; }
public List<ApiPackedItemDto> Items { get; set; } = new();
}
public class ApiPackedItemDto
{
public string Name { get; set; } = string.Empty;
public double LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
}
public class ApiPackingSummaryDto
{
public int TotalInStockBins { get; set; }
public int TotalToBePurchasedBins { get; set; }
public int TotalPieces { get; set; }
public double TotalMaterialInches { get; set; }
public string TotalMaterialFormatted { get; set; } = string.Empty;
public double TotalUsedInches { get; set; }
public string TotalUsedFormatted { get; set; } = string.Empty;
public double TotalWasteInches { get; set; }
public string TotalWasteFormatted { get; set; } = string.Empty;
public double Efficiency { get; set; }
public int TotalItemsNotPlaced { get; set; }
public List<ApiMaterialPackingSummaryDto> MaterialSummaries { get; set; } = new();
}
public class ApiMaterialPackingSummaryDto
{
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public int InStockBins { get; set; }
public int ToBePurchasedBins { get; set; }
public int TotalPieces { get; set; }
public double TotalMaterialInches { get; set; }
public double TotalUsedInches { get; set; }
public double TotalWasteInches { get; set; }
public double Efficiency { get; set; }
public int ItemsNotPlaced { get; set; }
}
#endregion
#region API Response DTOs Inventory
public class ApiSupplierDto
{
+619
View File
@@ -0,0 +1,619 @@
using System.ComponentModel;
using ModelContextProtocol.Server;
namespace CutList.Mcp;
/// <summary>
/// MCP tools for job management - creating jobs, managing parts/stock, and running optimization.
/// All calls go through the CutList.Web REST API via ApiClient.
/// </summary>
[McpServerToolType]
public class JobTools
{
private readonly ApiClient _api;
public JobTools(ApiClient api)
{
_api = api;
}
#region Jobs
[McpServerTool(Name = "list_jobs"), Description("Lists all jobs in the system with summary info (job number, name, customer, part/stock counts).")]
public async Task<JobListResult> ListJobs()
{
var jobs = await _api.GetJobsAsync();
return new JobListResult
{
Success = true,
Jobs = jobs.Select(j => new JobSummaryDto
{
Id = j.Id,
JobNumber = j.JobNumber,
Name = j.Name,
Customer = j.Customer,
CuttingToolId = j.CuttingToolId,
CuttingToolName = j.CuttingToolName,
Notes = j.Notes,
CreatedAt = j.CreatedAt,
UpdatedAt = j.UpdatedAt,
PartCount = j.PartCount,
StockCount = j.StockCount
}).ToList()
};
}
[McpServerTool(Name = "get_job"), Description("Gets full job details including all parts and stock assignments.")]
public async Task<JobDetailResult> GetJob(
[Description("Job ID")]
int jobId)
{
try
{
var job = await _api.GetJobAsync(jobId);
if (job == null)
return new JobDetailResult { Success = false, Error = $"Job {jobId} not found" };
return new JobDetailResult
{
Success = true,
Job = MapJobDetail(job)
};
}
catch (HttpRequestException ex)
{
return new JobDetailResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "create_job"), Description("Creates a new job. Returns the created job with its auto-generated job number.")]
public async Task<JobDetailResult> CreateJob(
[Description("Job name/description")]
string? name = null,
[Description("Customer name")]
string? customer = null,
[Description("Cutting tool ID (use list_cutting_tools to find IDs). If not set, uses the default tool.")]
int? cuttingToolId = null,
[Description("Notes about the job")]
string? notes = null)
{
try
{
var job = await _api.CreateJobAsync(name, customer, cuttingToolId, notes);
if (job == null)
return new JobDetailResult { Success = false, Error = "Failed to create job" };
return new JobDetailResult
{
Success = true,
Job = MapJobDetail(job)
};
}
catch (HttpRequestException ex)
{
return new JobDetailResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "update_job"), Description("Updates job details (name, customer, cutting tool, notes). Only provided fields are updated.")]
public async Task<JobDetailResult> UpdateJob(
[Description("Job ID")]
int jobId,
[Description("New job name")]
string? name = null,
[Description("New customer name")]
string? customer = null,
[Description("New cutting tool ID")]
int? cuttingToolId = null,
[Description("New notes")]
string? notes = null)
{
try
{
var job = await _api.UpdateJobAsync(jobId, name, customer, cuttingToolId, notes);
if (job == null)
return new JobDetailResult { Success = false, Error = $"Job {jobId} not found" };
return new JobDetailResult
{
Success = true,
Job = MapJobDetail(job)
};
}
catch (HttpRequestException ex)
{
return new JobDetailResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "delete_job"), Description("Deletes a job and all its parts and stock assignments.")]
public async Task<SimpleResult> DeleteJob(
[Description("Job ID")]
int jobId)
{
try
{
await _api.DeleteJobAsync(jobId);
return new SimpleResult { Success = true };
}
catch (HttpRequestException ex)
{
return new SimpleResult { Success = false, Error = ex.Message };
}
}
#endregion
#region Parts
[McpServerTool(Name = "add_job_part"), Description("Adds a single part to a job.")]
public async Task<JobPartResult> AddJobPart(
[Description("Job ID")]
int jobId,
[Description("Material ID (use list_materials to find IDs)")]
int materialId,
[Description("Part name/label (e.g., 'Top Rail', 'Picket')")]
string name,
[Description("Part length (e.g., '36\"', '4\\' 6\"', '54.5')")]
string length,
[Description("Quantity needed (default 1)")]
int quantity = 1)
{
try
{
var part = await _api.AddJobPartAsync(jobId, materialId, name, length, quantity);
if (part == null)
return new JobPartResult { Success = false, Error = "Failed to add part" };
return new JobPartResult
{
Success = true,
Part = MapPart(part)
};
}
catch (HttpRequestException ex)
{
return new JobPartResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "add_job_parts"), Description("Batch adds multiple parts to a job. Ideal for entering a full BOM (bill of materials). Returns the complete job state after all parts are added.")]
public async Task<JobDetailResult> AddJobParts(
[Description("Job ID")]
int jobId,
[Description("Array of parts to add. Each needs: materialId (int), name (string), length (string like \"36\\\"\" or \"4' 6\\\"\"), quantity (int)")]
PartEntry[] parts)
{
var errors = new List<string>();
int added = 0;
foreach (var part in parts)
{
try
{
await _api.AddJobPartAsync(jobId, part.MaterialId, part.Name, part.Length, part.Quantity);
added++;
}
catch (HttpRequestException ex)
{
errors.Add($"Failed to add '{part.Name}': {ex.Message}");
}
}
// Reload the full job state
try
{
var job = await _api.GetJobAsync(jobId);
if (job == null)
return new JobDetailResult { Success = false, Error = $"Job {jobId} not found after adding parts" };
var result = new JobDetailResult
{
Success = errors.Count == 0,
Job = MapJobDetail(job)
};
if (errors.Count > 0)
result.Error = $"Added {added}/{parts.Length} parts. Errors: {string.Join("; ", errors)}";
return result;
}
catch (HttpRequestException ex)
{
return new JobDetailResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "delete_job_part"), Description("Removes a part from a job.")]
public async Task<SimpleResult> DeleteJobPart(
[Description("Job ID")]
int jobId,
[Description("Part ID (from get_job results)")]
int partId)
{
try
{
await _api.DeleteJobPartAsync(jobId, partId);
return new SimpleResult { Success = true };
}
catch (HttpRequestException ex)
{
return new SimpleResult { Success = false, Error = ex.Message };
}
}
#endregion
#region Stock
[McpServerTool(Name = "add_job_stock"), Description("Adds a stock material assignment to a job. Stock defines what material lengths are available for cutting.")]
public async Task<JobStockResult> AddJobStock(
[Description("Job ID")]
int jobId,
[Description("Material ID (must match the material used by parts)")]
int materialId,
[Description("Stock length (e.g., '20'', '240', '20 ft')")]
string length,
[Description("Quantity available (-1 for unlimited, default -1)")]
int quantity = -1,
[Description("Stock item ID from inventory (optional - links to tracked inventory)")]
int? stockItemId = null,
[Description("True if this is a custom length not from inventory (default false)")]
bool isCustomLength = false,
[Description("Priority - lower number = used first (default 10)")]
int priority = 10)
{
try
{
var stock = await _api.AddJobStockAsync(jobId, materialId, stockItemId, length, quantity, isCustomLength, priority);
if (stock == null)
return new JobStockResult { Success = false, Error = "Failed to add stock" };
return new JobStockResult
{
Success = true,
Stock = MapStock(stock)
};
}
catch (HttpRequestException ex)
{
return new JobStockResult { Success = false, Error = ex.Message };
}
}
[McpServerTool(Name = "delete_job_stock"), Description("Removes a stock assignment from a job.")]
public async Task<SimpleResult> DeleteJobStock(
[Description("Job ID")]
int jobId,
[Description("Stock ID (from get_job results)")]
int stockId)
{
try
{
await _api.DeleteJobStockAsync(jobId, stockId);
return new SimpleResult { Success = true };
}
catch (HttpRequestException ex)
{
return new SimpleResult { Success = false, Error = ex.Message };
}
}
#endregion
#region Optimization
[McpServerTool(Name = "optimize_job"), Description("Runs bin packing optimization on a job. The job must have parts defined. If stock is defined, it will be used; otherwise the optimizer uses available inventory. Returns optimized cut layouts per material with efficiency stats.")]
public async Task<OptimizeJobResult> OptimizeJob(
[Description("Job ID")]
int jobId,
[Description("Optional kerf override in inches (e.g., 0.125). If not set, uses the job's cutting tool kerf.")]
double? kerfOverride = null)
{
try
{
var result = await _api.PackJobAsync(jobId, kerfOverride.HasValue ? (decimal)kerfOverride.Value : null);
if (result == null)
return new OptimizeJobResult { Success = false, Error = "Optimization returned no results" };
return new OptimizeJobResult
{
Success = true,
Materials = result.Materials.Select(m => new OptMaterialResultDto
{
MaterialId = m.MaterialId,
MaterialName = m.MaterialName,
InStockBins = m.InStockBins.Select(MapBin).ToList(),
ToBePurchasedBins = m.ToBePurchasedBins.Select(MapBin).ToList(),
ItemsNotPlaced = m.ItemsNotPlaced.Select(i => new OptItemDto { Name = i.Name, LengthInches = i.LengthInches, LengthFormatted = i.LengthFormatted }).ToList(),
Summary = MapMaterialSummary(m.Summary)
}).ToList(),
Summary = new OptSummaryDto
{
TotalInStockBins = result.Summary.TotalInStockBins,
TotalToBePurchasedBins = result.Summary.TotalToBePurchasedBins,
TotalPieces = result.Summary.TotalPieces,
TotalMaterialFormatted = result.Summary.TotalMaterialFormatted,
TotalUsedFormatted = result.Summary.TotalUsedFormatted,
TotalWasteFormatted = result.Summary.TotalWasteFormatted,
Efficiency = result.Summary.Efficiency,
TotalItemsNotPlaced = result.Summary.TotalItemsNotPlaced
}
};
}
catch (HttpRequestException ex)
{
return new OptimizeJobResult { Success = false, Error = ex.Message };
}
}
#endregion
#region Cutting Tools
[McpServerTool(Name = "list_cutting_tools"), Description("Lists all available cutting tools with their kerf (blade width) values.")]
public async Task<CuttingToolListResult> ListCuttingTools(
[Description("Include inactive tools (default false)")]
bool includeInactive = false)
{
var tools = await _api.GetCuttingToolsAsync(includeInactive);
return new CuttingToolListResult
{
Success = true,
Tools = tools.Select(t => new CuttingToolSummaryDto
{
Id = t.Id,
Name = t.Name,
KerfInches = t.KerfInches,
IsDefault = t.IsDefault,
IsActive = t.IsActive
}).ToList()
};
}
#endregion
#region Mapping Helpers
private static JobDetailDto MapJobDetail(ApiJobDetailDto j) => new()
{
Id = j.Id,
JobNumber = j.JobNumber,
Name = j.Name,
Customer = j.Customer,
CuttingToolId = j.CuttingToolId,
CuttingToolName = j.CuttingToolName,
Notes = j.Notes,
CreatedAt = j.CreatedAt,
UpdatedAt = j.UpdatedAt,
PartCount = j.PartCount,
StockCount = j.StockCount,
Parts = j.Parts.Select(MapPart).ToList(),
Stock = j.Stock.Select(MapStock).ToList()
};
private static JobPartSummaryDto MapPart(ApiJobPartDto p) => new()
{
Id = p.Id,
MaterialId = p.MaterialId,
MaterialName = p.MaterialName,
Name = p.Name,
LengthInches = p.LengthInches,
LengthFormatted = p.LengthFormatted,
Quantity = p.Quantity
};
private static JobStockSummaryDto MapStock(ApiJobStockDto s) => new()
{
Id = s.Id,
MaterialId = s.MaterialId,
MaterialName = s.MaterialName,
StockItemId = s.StockItemId,
LengthInches = s.LengthInches,
LengthFormatted = s.LengthFormatted,
Quantity = s.Quantity,
IsCustomLength = s.IsCustomLength,
Priority = s.Priority
};
private static OptBinDto MapBin(ApiPackedBinDto b) => new()
{
LengthFormatted = b.LengthFormatted,
UsedFormatted = b.UsedFormatted,
WasteFormatted = b.WasteFormatted,
Efficiency = Math.Round(b.Efficiency, 1),
Items = b.Items.Select(i => new OptItemDto
{
Name = i.Name,
LengthInches = i.LengthInches,
LengthFormatted = i.LengthFormatted
}).ToList()
};
private static OptMaterialSummaryDto MapMaterialSummary(ApiMaterialPackingSummaryDto s) => new()
{
InStockBins = s.InStockBins,
ToBePurchasedBins = s.ToBePurchasedBins,
TotalPieces = s.TotalPieces,
Efficiency = Math.Round(s.Efficiency, 1),
ItemsNotPlaced = s.ItemsNotPlaced
};
#endregion
}
#region Job Tool DTOs
public class PartEntry
{
public int MaterialId { get; set; }
public string Name { get; set; } = string.Empty;
public string Length { get; set; } = string.Empty;
public int Quantity { get; set; } = 1;
}
public class SimpleResult
{
public bool Success { get; set; }
public string? Error { get; set; }
}
public class JobSummaryDto
{
public int Id { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string? Name { get; set; }
public string? Customer { get; set; }
public int? CuttingToolId { get; set; }
public string? CuttingToolName { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int PartCount { get; set; }
public int StockCount { get; set; }
}
public class JobDetailDto
{
public int Id { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string? Name { get; set; }
public string? Customer { get; set; }
public int? CuttingToolId { get; set; }
public string? CuttingToolName { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int PartCount { get; set; }
public int StockCount { get; set; }
public List<JobPartSummaryDto> Parts { get; set; } = new();
public List<JobStockSummaryDto> Stock { get; set; } = new();
}
public class JobPartSummaryDto
{
public int Id { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int Quantity { get; set; }
}
public class JobStockSummaryDto
{
public int Id { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public int? StockItemId { get; set; }
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public int Quantity { get; set; }
public bool IsCustomLength { get; set; }
public int Priority { get; set; }
}
public class JobListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<JobSummaryDto> Jobs { get; set; } = new();
}
public class JobDetailResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public JobDetailDto? Job { get; set; }
}
public class JobPartResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public JobPartSummaryDto? Part { get; set; }
}
public class JobStockResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public JobStockSummaryDto? Stock { get; set; }
}
public class CuttingToolSummaryDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal KerfInches { get; set; }
public bool IsDefault { get; set; }
public bool IsActive { get; set; }
}
public class CuttingToolListResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<CuttingToolSummaryDto> Tools { get; set; } = new();
}
// Optimization result DTOs — streamlined for LLM consumption
public class OptimizeJobResult
{
public bool Success { get; set; }
public string? Error { get; set; }
public List<OptMaterialResultDto> Materials { get; set; } = new();
public OptSummaryDto? Summary { get; set; }
}
public class OptMaterialResultDto
{
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public List<OptBinDto> InStockBins { get; set; } = new();
public List<OptBinDto> ToBePurchasedBins { get; set; } = new();
public List<OptItemDto> ItemsNotPlaced { get; set; } = new();
public OptMaterialSummaryDto Summary { get; set; } = new();
}
public class OptBinDto
{
public string LengthFormatted { get; set; } = string.Empty;
public string UsedFormatted { get; set; } = string.Empty;
public string WasteFormatted { get; set; } = string.Empty;
public double Efficiency { get; set; }
public List<OptItemDto> Items { get; set; } = new();
}
public class OptItemDto
{
public string Name { get; set; } = string.Empty;
public double LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
}
public class OptSummaryDto
{
public int TotalInStockBins { get; set; }
public int TotalToBePurchasedBins { get; set; }
public int TotalPieces { get; set; }
public string TotalMaterialFormatted { get; set; } = string.Empty;
public string TotalUsedFormatted { get; set; } = string.Empty;
public string TotalWasteFormatted { get; set; } = string.Empty;
public double Efficiency { get; set; }
public int TotalItemsNotPlaced { get; set; }
}
public class OptMaterialSummaryDto
{
public int InStockBins { get; set; }
public int ToBePurchasedBins { get; set; }
public int TotalPieces { get; set; }
public double Efficiency { get; set; }
public int ItemsNotPlaced { get; set; }
}
#endregion
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/report.css" />
<link rel="stylesheet" href="CutList.Web.styles.css" />
@@ -28,6 +28,11 @@
<span class="bi bi-boxes-nav-menu" aria-hidden="true"></span> Stock Items
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="orders">
<span class="bi bi-cart-nav-menu" aria-hidden="true"></span> Orders
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="suppliers">
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Suppliers
@@ -50,6 +50,10 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-boxes' viewBox='0 0 16 16'%3E%3Cpath d='M7.752.066a.5.5 0 0 1 .496 0l3.75 2.143a.5.5 0 0 1 .252.434v3.995l3.498 2A.5.5 0 0 1 16 9.07v4.286a.5.5 0 0 1-.252.434l-3.75 2.143a.5.5 0 0 1-.496 0l-3.502-2-3.502 2.001a.5.5 0 0 1-.496 0l-3.75-2.143A.5.5 0 0 1 0 13.357V9.071a.5.5 0 0 1 .252-.434L3.75 6.638V2.643a.5.5 0 0 1 .252-.434zM4.25 7.504 1.508 9.071l2.742 1.567 2.742-1.567zM7.5 9.933l-2.75 1.571v3.134l2.75-1.571zm1 3.134 2.75 1.571v-3.134L8.5 9.933zm.508-3.996 2.742 1.567 2.742-1.567-2.742-1.567zm2.242-2.433V3.504L8.5 5.076V8.21zM7.5 8.21V5.076L4.75 3.504v3.134zM5.258 2.643 8 4.21l2.742-1.567L8 1.076zM15 9.933l-2.75 1.571v3.134L15 13.067zM3.75 14.638v-3.134L1 9.933v3.134z'/%3E%3C/svg%3E");
}
.bi-cart-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-cart3' viewBox='0 0 16 16'%3E%3Cpath d='M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5zM3.102 4l.84 4.479 9.144-.459L13.89 4H3.102zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z'/%3E%3C/svg%3E");
}
.bi-building-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-building' viewBox='0 0 16 16'%3E%3Cpath d='M4 2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3 0a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1ZM4 5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1ZM4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Zm2.5.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1Zm3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1Z'/%3E%3Cpath d='M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z'/%3E%3C/svg%3E");
}
+640 -102
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,12 +16,22 @@
<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)
{
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-lock-fill me-2"></i>
<strong>This job is locked</strong> — materials ordered on @job.LockedAt!.Value.ToLocalTime().ToString("g"). Unlock to make changes.
</div>
<button class="btn btn-outline-warning btn-sm" @onclick="UnlockJob">
<i class="bi bi-unlock"></i> Unlock Job
</button>
</div>
}
@if (loading)
{
<p><em>Loading...</em></p>
@@ -60,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">
@@ -79,6 +104,10 @@ else
{
@RenderStockTab()
}
else if (activeTab == Tab.Results)
{
@RenderResultsTab()
}
</div>
}
@@ -89,14 +118,15 @@ else
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingPart == null ? "Add Part" : "Edit Part")</h5>
<h5 class="modal-title">@(editingPart == null ? "Add Parts" : "Edit Part")</h5>
<button type="button" class="btn-close" @onclick="CancelPartForm"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Shape</label>
<select class="form-select" @bind="selectedShape" @bind:after="OnShapeChanged">
<select class="form-select" @bind="selectedShape" @bind:after="OnShapeChanged"
disabled="@(editingPart != null)">
<option value="">-- Select --</option>
@foreach (var shape in DistinctShapes)
{
@@ -106,7 +136,7 @@ else
</div>
<div class="col-md-6">
<label class="form-label">Size</label>
<select class="form-select" @bind="newPart.MaterialId" disabled="@(!selectedShape.HasValue)">
<select class="form-select" @bind="partSelectedMaterialId" disabled="@(!selectedShape.HasValue || editingPart != null)">
<option value="0">-- Select --</option>
@foreach (var material in FilteredMaterials)
{
@@ -114,19 +144,63 @@ else
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newPart.LengthInches" />
</div>
<div class="col-md-6">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newPart.Quantity" min="1" />
</div>
<div class="col-12">
<label class="form-label">Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" @bind="newPart.Name" placeholder="Part name" />
</div>
</div>
@if (editingPart != null)
{
@* Edit mode: single row *@
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Length</label>
<LengthInput @bind-Value="newPart.LengthInches" />
</div>
<div class="col-md-4">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" @bind="newPart.Quantity" min="1" />
</div>
<div class="col-md-4">
<label class="form-label">Name <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" class="form-control" @bind="newPart.Name" placeholder="Part name" />
</div>
</div>
}
else
{
@* Add mode: multi-row table *@
<table class="table table-sm align-middle mb-2">
<thead>
<tr>
<th>Length</th>
<th style="width: 100px;">Qty</th>
<th>Name <span class="text-muted fw-normal">(optional)</span></th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
@for (var i = 0; i < partRows.Count; i++)
{
var row = partRows[i];
<tr>
<td><LengthInput @bind-Value="row.LengthInches" /></td>
<td><input type="number" class="form-control form-control-sm" @bind="row.Quantity" @bind:event="oninput" min="1" /></td>
<td><input type="text" class="form-control form-control-sm" @bind="row.Name" @bind:event="oninput" placeholder="Part name" /></td>
<td>
@if (partRows.Count > 1)
{
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="() => RemovePartRow(row)" title="Remove">
<i class="bi bi-x-lg"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="AddPartRow">
<i class="bi bi-plus-lg me-1"></i>Add Row
</button>
}
@if (!string.IsNullOrEmpty(partErrorMessage))
{
<div class="alert alert-danger mt-3 mb-0">@partErrorMessage</div>
@@ -135,7 +209,14 @@ else
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelPartForm">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SavePartAsync">
@(editingPart == null ? "Add Part" : "Save Changes")
@if (editingPart != null)
{
<text>Save Changes</text>
}
else
{
<text>Add @partRows.Count Part@(partRows.Count != 1 ? "s" : "")</text>
}
</button>
</div>
</div>
@@ -240,7 +321,7 @@ else
}
@code {
private enum Tab { Details, Parts, Stock }
private enum Tab { Details, Parts, Stock, Results }
[Parameter]
public int? Id { get; set; }
@@ -262,6 +343,8 @@ else
private JobPart? editingPart;
private string? partErrorMessage;
private MaterialShape? selectedShape;
private int partSelectedMaterialId;
private List<PartRow> partRows = new();
// Stock form
private bool showStockForm;
@@ -279,12 +362,29 @@ 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()
{
if (Id.HasValue)
{
await JobService.UnlockAsync(Id.Value);
job = (await JobService.GetByIdAsync(Id.Value))!;
}
}
protected override async Task OnInitializedAsync()
{
@@ -300,6 +400,12 @@ else
return;
}
job = existing;
// Load saved optimization results if available
if (job.OptimizationResultJson != null)
{
LoadSavedResults();
}
}
else
{
@@ -314,6 +420,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">
@@ -322,55 +447,57 @@ else
</div>
<div class="card-body">
<EditForm Model="job" OnValidSubmit="SaveJobAsync">
@if (!IsNew)
{
<fieldset disabled="@job.IsLocked">
@if (!IsNew)
{
<div class="mb-3">
<label class="form-label">Job Number</label>
<input type="text" class="form-control" value="@job.JobNumber" readonly />
</div>
}
<div class="mb-3">
<label class="form-label">Job Number</label>
<input type="text" class="form-control" value="@job.JobNumber" readonly />
<label class="form-label">Job Name <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Name" placeholder="Descriptive name for this job" />
</div>
}
<div class="mb-3">
<label class="form-label">Job Name <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Name" placeholder="Descriptive name for this job" />
</div>
<div class="mb-3">
<label class="form-label">Customer <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Customer" placeholder="Customer name" />
</div>
<div class="mb-3">
<label class="form-label">Customer <span class="text-muted fw-normal">(optional)</span></label>
<InputText class="form-control" @bind-Value="job.Customer" placeholder="Customer name" />
</div>
<div class="mb-3">
<label class="form-label">Cutting Tool</label>
<InputSelect class="form-select" @bind-Value="job.CuttingToolId">
<option value="">-- Select Tool --</option>
@foreach (var tool in cuttingTools)
{
<option value="@tool.Id">@tool.Name (@tool.KerfInches" kerf)</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Cutting Tool</label>
<InputSelect class="form-select" @bind-Value="job.CuttingToolId">
<option value="">-- Select Tool --</option>
@foreach (var tool in cuttingTools)
{
<option value="@tool.Id">@tool.Name (@tool.KerfInches" kerf)</option>
}
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="job.Notes" rows="3" />
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<InputTextArea class="form-control" @bind-Value="job.Notes" rows="3" />
</div>
@if (!string.IsNullOrEmpty(jobErrorMessage))
{
<div class="alert alert-danger">@jobErrorMessage</div>
}
@if (!string.IsNullOrEmpty(jobErrorMessage))
{
<div class="alert alert-danger">@jobErrorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingJob">
@if (savingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Job" : "Save")
</button>
<a href="jobs" class="btn btn-outline-secondary">Back</a>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@savingJob">
@if (savingJob)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
@(IsNew ? "Create Job" : "Save")
</button>
<a href="jobs" class="btn btn-outline-secondary">Back</a>
</div>
</fieldset>
</EditForm>
</div>
</div>
@@ -381,7 +508,10 @@ else
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Parts to Cut</h5>
<button class="btn btn-primary" @onclick="ShowAddPartForm">Add Part</button>
@if (!job.IsLocked)
{
<button class="btn btn-primary" @onclick="ShowAddPartForm">Add Part</button>
}
</div>
<div class="card-body">
@if (job.Parts.Count == 0)
@@ -413,8 +543,11 @@ else
<td>@part.Quantity</td>
<td>@(string.IsNullOrWhiteSpace(part.Name) ? "-" : part.Name)</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)">Delete</button>
@if (!job.IsLocked)
{
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditPart(part)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeletePart(part)" title="Delete"><i class="bi bi-trash"></i></button>
}
</td>
</tr>
}
@@ -444,6 +577,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
@@ -458,15 +595,28 @@ else
editingPart = null;
newPart = new JobPart { JobId = Id!.Value, Quantity = 1 };
selectedShape = null;
partSelectedMaterialId = 0;
partRows = new List<PartRow> { new PartRow() };
showPartForm = true;
partErrorMessage = null;
}
private void OnShapeChanged()
{
partSelectedMaterialId = 0;
newPart.MaterialId = 0;
}
private void AddPartRow()
{
partRows.Add(new PartRow());
}
private void RemovePartRow(PartRow row)
{
partRows.Remove(row);
}
private void EditPart(JobPart part)
{
editingPart = part;
@@ -481,6 +631,8 @@ else
SortOrder = part.SortOrder
};
selectedShape = part.Material?.Shape;
partSelectedMaterialId = part.MaterialId;
partRows.Clear();
showPartForm = true;
partErrorMessage = null;
}
@@ -501,42 +653,75 @@ else
return;
}
if (newPart.MaterialId == 0)
if (partSelectedMaterialId == 0)
{
partErrorMessage = "Please select a size";
return;
}
if (newPart.LengthInches <= 0)
if (editingPart != null)
{
partErrorMessage = "Length must be greater than zero";
return;
}
// Edit mode: single part
if (newPart.LengthInches <= 0)
{
partErrorMessage = "Length must be greater than zero";
return;
}
if (newPart.Quantity < 1)
{
partErrorMessage = "Quantity must be at least 1";
return;
}
if (newPart.Quantity < 1)
{
partErrorMessage = "Quantity must be at least 1";
return;
}
if (editingPart == null)
{
await JobService.AddPartAsync(newPart);
newPart.MaterialId = partSelectedMaterialId;
await JobService.UpdatePartAsync(newPart);
}
else
{
await JobService.UpdatePartAsync(newPart);
// Add mode: multiple rows
for (int i = 0; i < partRows.Count; i++)
{
var row = partRows[i];
if (row.LengthInches <= 0)
{
partErrorMessage = $"Row {i + 1}: Length must be greater than zero";
return;
}
if (row.Quantity < 1)
{
partErrorMessage = $"Row {i + 1}: Quantity must be at least 1";
return;
}
}
foreach (var row in partRows)
{
var part = new JobPart
{
JobId = Id!.Value,
MaterialId = partSelectedMaterialId,
LengthInches = row.LengthInches,
Quantity = row.Quantity,
Name = row.Name ?? string.Empty
};
await JobService.AddPartAsync(part);
}
}
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
@@ -545,16 +730,16 @@ else
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Stock for This Job</h5>
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<div class="btn-group">
<button class="btn btn-primary" @onclick="ShowAddStockFromInventory">Add from Inventory</button>
<button class="btn btn-outline-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
@if (!job.IsLocked)
{
<div class="d-flex gap-2">
<button class="btn btn-success" @onclick="ShowImportModal" disabled="@(job.Parts.Count == 0)"
title="@(job.Parts.Count == 0 ? "Add parts first to match against inventory" : "Find and import stock matching your parts")">
Import from Inventory
</button>
<button class="btn btn-primary" @onclick="ShowAddCustomStock">Add Custom Length</button>
</div>
</div>
}
</div>
<div class="card-body">
@if (showStockForm)
@@ -731,8 +916,11 @@ else
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)">Delete</button>
@if (!job.IsLocked)
{
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => EditStock(stock)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteStock(stock)" title="Delete"><i class="bi bi-trash"></i></button>
}
</td>
</tr>
}
@@ -741,16 +929,351 @@ 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>
<!-- Purchase List -->
<div class="card mb-4 print-purchase-list">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-cart me-2"></i>Purchase List</h5>
@if (summary.TotalToBePurchasedBins > 0)
{
@if (addedToOrderList)
{
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Added to orders</span>
}
else
{
<button class="btn btn-warning btn-sm" @onclick="AddToOrderList" disabled="@addingToOrderList">
@if (addingToOrderList)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<i class="bi bi-cart-plus me-1"></i>Add to Order List
</button>
}
}
</div>
<div class="card-body">
@if (summary.TotalToBePurchasedBins == 0)
{
<p class="text-muted mb-0">Everything is available in stock. No purchases needed.</p>
}
else
{
@if (addedToOrderList)
{
<div class="alert alert-success py-2 mb-3">
Items added to order list. <a href="orders">View Orders</a>
</div>
}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th class="text-end">Qty</th>
</tr>
</thead>
<tbody>
@foreach (var materialResult in packResult.MaterialResults.Where(mr => mr.ToBePurchasedBins.Count > 0))
{
@foreach (var group in materialResult.ToBePurchasedBins.GroupBy(b => b.Length).OrderByDescending(g => g.Key))
{
<tr>
<td>@materialResult.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches(group.Key)</td>
<td class="text-end">@group.Count()</td>
</tr>
}
}
</tbody>
<tfoot>
<tr class="fw-bold">
<td colspan="2">Total</td>
<td class="text-end">@summary.TotalToBePurchasedBins bars</td>
</tr>
</tfoot>
</table>
</div>
}
</div>
</div>
<!-- Cut Lists by Material -->
@foreach (var materialResult in packResult.MaterialResults)
{
var materialSummary = summary.MaterialSummaries.First(s => s.Material.Id == materialResult.Material.Id);
var allBins = materialResult.InStockBins
.Select(b => new { Bin = b, Source = "Stock" })
.Concat(materialResult.ToBePurchasedBins
.Select(b => new { Bin = b, Source = "Purchase" }))
.ToList();
<div class="card mb-4 cutlist-material-card">
<div class="card-header cutlist-material-screen-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">@materialResult.Material.DisplayName</h5>
<span class="text-muted">
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
&middot; @materialSummary.TotalPieces pieces
&middot; @materialSummary.Efficiency.ToString("F1")% efficiency
</span>
</div>
</div>
<div class="card-body">
@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>
}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0">
<thead>
<tr class="cutlist-material-print-header">
<th colspan="5">
<span class="cutlist-material-name">@materialResult.Material.DisplayName</span>
<span class="cutlist-material-stats">
@(materialSummary.InStockBins + materialSummary.ToBePurchasedBins) bars
&middot; @materialSummary.TotalPieces pieces
&middot; @materialSummary.Efficiency.ToString("F1")% efficiency
</span>
</th>
</tr>
<tr>
<th style="width: 50px;">#</th>
<th style="width: 90px;">Source</th>
<th style="white-space: nowrap;">Stock Length</th>
<th>Cuts</th>
<th style="width: 120px; white-space: nowrap;">Waste</th>
</tr>
</thead>
<tbody>
@{ var binNum = 1; }
@foreach (var entry in allBins)
{
<tr>
<td>@binNum</td>
<td>
@if (entry.Source == "Stock")
{
<span class="badge bg-success">Stock</span>
}
else
{
<span class="badge bg-warning text-dark">Purchase</span>
}
</td>
<td style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.Length)</td>
<td>
@foreach (var item in entry.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 style="white-space: nowrap;">@ArchUnits.FormatFromInches(entry.Bin.RemainingLength)</td>
</tr>
binNum++;
}
</tbody>
</table>
</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 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()
@@ -875,6 +1398,8 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showStockForm = false;
editingStock = null;
packResult = null;
summary = null;
}
private async Task SaveCustomStockAsync()
@@ -920,12 +1445,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
@@ -1012,6 +1541,8 @@ else
job = (await JobService.GetByIdAsync(Id!.Value))!;
showImportModal = false;
importCandidates.Clear();
packResult = null;
summary = null;
}
catch (Exception ex)
{
@@ -1019,6 +1550,13 @@ else
}
}
private class PartRow
{
public decimal LengthInches { get; set; }
public int Quantity { get; set; } = 1;
public string? Name { get; set; }
}
private class ImportStockCandidate
{
public StockItem StockItem { get; set; } = null!;
+24 -7
View File
@@ -43,28 +43,35 @@ else
<th>Customer</th>
<th>Cutting Tool</th>
<th>Last Modified</th>
<th style="width: 200px;">Actions</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var job in jobs)
@foreach (var job in pagedJobs)
{
<tr>
<td><a href="jobs/@job.Id">@job.JobNumber</a></td>
<td>
<a href="jobs/@job.Id">@job.JobNumber</a>
@if (job.IsLocked)
{
<i class="bi bi-lock-fill text-warning ms-1" title="Locked — materials ordered"></i>
}
</td>
<td>@(job.Name ?? "-")</td>
<td>@(job.Customer ?? "-")</td>
<td>@(job.CuttingTool?.Name ?? "-")</td>
<td>@((job.UpdatedAt ?? job.CreatedAt).ToLocalTime().ToString("g"))</td>
<td>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="jobs/@job.Id/results" class="btn btn-sm btn-success">Optimize</a>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => DuplicateJob(job)">Copy</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(job)">Delete</button>
<a href="jobs/@job.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></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>
</tr>
}
</tbody>
</table>
<Pager TotalCount="jobs.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
<ConfirmDialog @ref="deleteDialog"
@@ -77,10 +84,14 @@ else
private List<Job> jobs = new();
private bool loading = true;
private bool creating = false;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private Job? jobToDelete;
private string deleteMessage = "";
private IEnumerable<Job> pagedJobs => jobs.Skip((currentPage - 1) * pageSize).Take(pageSize);
protected override async Task OnInitializedAsync()
{
jobs = await JobService.GetAllAsync();
@@ -114,9 +125,15 @@ else
{
await JobService.DeleteAsync(jobToDelete.Id);
jobs = await JobService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)jobs.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private async Task DuplicateJob(Job job)
{
var duplicate = await JobService.DuplicateAsync(job.Id);
@@ -1,258 +0,0 @@
@page "/jobs/{Id:int}/results"
@inject JobService JobService
@inject CutListPackingService PackingService
@inject NavigationManager Navigation
@inject IJSRuntime JS
@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</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">
<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">
<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>
<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 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);
}
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 PrintReport()
{
var filename = $"CutList - {job!.Name} - {DateTime.Now:yyyy-MM-dd}";
await JS.InvokeVoidAsync("printWithTitle", filename);
}
}
@@ -33,36 +33,47 @@ else if (materials.Count == 0)
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th>Description</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var material in materials)
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredMaterials.Count == 0)
{
<div class="alert alert-warning">
No materials match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<td>@material.Shape.GetDisplayName()</td>
<td>@material.Type</td>
<td>@material.Grade</td>
<td>@material.Size</td>
<td>@material.Description</td>
<td>
<div class="d-flex gap-1">
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)">Delete</button>
</div>
</td>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th style="width: 100px;">Actions</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var material in pagedMaterials)
{
<tr>
<td>@material.Shape.GetDisplayName()</td>
<td>@material.Type</td>
<td>@material.Grade</td>
<td>@material.Size</td>
<td>
<div class="d-flex gap-1">
<a href="materials/@material.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(material)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredMaterials.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
<ConfirmDialog @ref="deleteDialog"
@@ -75,9 +86,42 @@ else
private List<Material> materials = new();
private bool loading = true;
private string? errorMessage;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private Material? materialToDelete;
private string deleteMessage = "";
private MaterialFilterState filterState = new();
private List<Material> filteredMaterials => materials.Where(m =>
{
if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value)
return false;
if (filterState.Type.HasValue && m.Type != filterState.Type.Value)
return false;
if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade)
return false;
if (!string.IsNullOrWhiteSpace(filterState.SearchText))
{
var search = filterState.SearchText.Trim();
if (!Contains(m.Size, search)
&& !Contains(m.Grade, search)
&& !Contains(m.Description, search)
&& !Contains(m.Shape.GetDisplayName(), search))
return false;
}
return true;
}).ToList();
private IEnumerable<string> availableGrades => materials
.Select(m => m.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<Material> pagedMaterials => filteredMaterials
.Skip((currentPage - 1) * pageSize)
.Take(pageSize);
protected override async Task OnInitializedAsync()
{
@@ -95,6 +139,12 @@ else
}
}
private void OnFilterChanged(MaterialFilterState state)
{
filterState = state;
currentPage = 1;
}
private void ConfirmDelete(Material material)
{
materialToDelete = material;
@@ -108,6 +158,15 @@ else
{
await MaterialService.DeleteAsync(materialToDelete.Id);
materials = await MaterialService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredMaterials.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private static bool Contains(string? value, string search) =>
value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,151 @@
@page "/orders/add"
@inject PurchaseItemService PurchaseItemService
@inject StockItemService StockItemService
@inject SupplierService SupplierService
@inject JobService JobService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Add Order Item</PageTitle>
<h1>Add Order Item</h1>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Order Item Details</h5>
</div>
<div class="card-body">
<EditForm Model="item" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Stock Item</label>
<select class="form-select" @bind="item.StockItemId">
<option value="0">-- Select Stock Item --</option>
@foreach (var group in stockItemGroups)
{
<optgroup label="@group.Key">
@foreach (var si in group.Value)
{
<option value="@si.Id">@si.Material.Size - @ArchUnits.FormatFromInches((double)si.LengthInches)</option>
}
</optgroup>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<InputNumber class="form-control" @bind-Value="item.Quantity" min="1" />
</div>
<div class="mb-3">
<label class="form-label">Supplier (optional)</label>
<select class="form-select" @bind="item.SupplierId">
<option value="">-- Select Supplier --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Job (optional)</label>
<select class="form-select" @bind="item.JobId">
<option value="">-- Select Job --</option>
@foreach (var job in jobs)
{
<option value="@job.Id">@job.DisplayName</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Notes (optional)</label>
<InputText class="form-control" @bind-Value="item.Notes" placeholder="Any notes about this order" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary" disabled="@saving">
@if (saving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Add to Order List
</button>
<a href="orders" class="btn btn-outline-secondary">Cancel</a>
</div>
</EditForm>
</div>
</div>
</div>
</div>
}
@code {
private PurchaseItem item = new() { Quantity = 1 };
private List<StockItem> stockItems = new();
private Dictionary<string, List<StockItem>> stockItemGroups = new();
private List<Supplier> suppliers = new();
private List<Job> jobs = new();
private bool loading = true;
private bool saving;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
stockItems = await StockItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
jobs = await JobService.GetAllAsync();
stockItemGroups = stockItems
.GroupBy(s => s.Material.Shape.GetDisplayName())
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.OrderBy(s => s.Material.Size).ThenBy(s => s.LengthInches).ToList());
loading = false;
}
private async Task SaveAsync()
{
errorMessage = null;
saving = true;
try
{
if (item.StockItemId == 0)
{
errorMessage = "Please select a stock item";
return;
}
if (item.Quantity <= 0)
{
errorMessage = "Quantity must be at least 1";
return;
}
await PurchaseItemService.CreateAsync(item);
Navigation.NavigateTo("orders");
}
finally
{
saving = false;
}
}
}
@@ -0,0 +1,291 @@
@page "/orders"
@inject PurchaseItemService PurchaseItemService
@inject SupplierService SupplierService
@inject NavigationManager Navigation
@using CutList.Core.Formatting
@using CutList.Web.Data.Entities
<PageTitle>Orders</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>To Be Ordered</h1>
<a href="orders/add" class="btn btn-primary">Add Item</a>
</div>
<p class="text-muted mb-4">
Track material that needs to be ordered from suppliers. Items are added manually or automatically from job optimization results.
</p>
@if (loading)
{
<p><em>Loading...</em></p>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeTab == "pending" ? "active" : "")" @onclick='() => SetTab("pending")'>
Pending
@if (pendingCount > 0)
{
<span class="badge bg-warning text-dark ms-1">@pendingCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "ordered" ? "active" : "")" @onclick='() => SetTab("ordered")'>
Ordered
@if (orderedCount > 0)
{
<span class="badge bg-primary ms-1">@orderedCount</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "all" ? "active" : "")" @onclick='() => SetTab("all")'>All</button>
</li>
</ul>
@if (tabItems.Count == 0)
{
<div class="alert alert-info">
@if (activeTab == "pending")
{
<span>No pending items. <a href="orders/add">Add an item</a> or use "Add to Order List" from a job's results page.</span>
}
else if (activeTab == "ordered")
{
<span>No ordered items.</span>
}
else
{
<span>No order items found. <a href="orders/add">Add your first item</a>.</span>
}
</div>
}
else
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredItems.Count == 0)
{
<div class="alert alert-warning">
No items match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Material</th>
<th>Length</th>
<th>Qty</th>
<th>Supplier</th>
<th>Job</th>
<th>Status</th>
<th>Notes</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in pagedItems)
{
<tr>
<td>@item.StockItem.Material.DisplayName</td>
<td>@ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)</td>
<td>@item.Quantity</td>
<td>
<select class="form-select form-select-sm" style="min-width: 140px;"
value="@(item.SupplierId?.ToString() ?? "")"
@onchange="(e) => OnSupplierChanged(item, e)">
<option value="">-- Select --</option>
@foreach (var supplier in suppliers)
{
<option value="@supplier.Id">@supplier.Name</option>
}
</select>
</td>
<td>
@if (item.Job != null)
{
<a href="jobs/@item.Job.Id">@item.Job.DisplayName</a>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td>
<span class="badge @GetStatusBadgeClass(item.Status)">@item.Status</span>
</td>
<td>
<span class="text-muted small">@(item.Notes ?? "-")</span>
</td>
<td>
<div class="d-flex gap-1">
@if (item.Status == PurchaseItemStatus.Pending)
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => MarkOrdered(item)" title="Mark Ordered">
<i class="bi bi-truck"></i>
</button>
}
@if (item.Status == PurchaseItemStatus.Ordered)
{
<button class="btn btn-sm btn-outline-success" @onclick="() => MarkReceived(item)" title="Mark Received">
<i class="bi bi-check-lg"></i>
</button>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredItems.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
}
<ConfirmDialog @ref="deleteDialog"
Title="Delete Order Item"
Message="@deleteMessage"
ConfirmText="Delete"
OnConfirm="DeleteConfirmed" />
@code {
private List<PurchaseItem> allItems = new();
private List<Supplier> suppliers = new();
private bool loading = true;
private string activeTab = "pending";
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private PurchaseItem? itemToDelete;
private string deleteMessage = "";
private MaterialFilterState filterState = new();
private int pendingCount => allItems.Count(i => i.Status == PurchaseItemStatus.Pending);
private int orderedCount => allItems.Count(i => i.Status == PurchaseItemStatus.Ordered);
private List<PurchaseItem> tabItems => activeTab switch
{
"pending" => allItems.Where(i => i.Status == PurchaseItemStatus.Pending).ToList(),
"ordered" => allItems.Where(i => i.Status == PurchaseItemStatus.Ordered).ToList(),
_ => allItems
};
private List<PurchaseItem> filteredItems => tabItems.Where(i =>
{
var m = i.StockItem.Material;
if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value)
return false;
if (filterState.Type.HasValue && m.Type != filterState.Type.Value)
return false;
if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade)
return false;
if (!string.IsNullOrWhiteSpace(filterState.SearchText))
{
var search = filterState.SearchText.Trim();
if (!Contains(m.Size, search)
&& !Contains(m.Grade, search)
&& !Contains(m.Shape.GetDisplayName(), search)
&& !Contains(i.Notes, search)
&& !Contains(i.Job?.DisplayName, search)
&& !Contains(i.Supplier?.Name, search))
return false;
}
return true;
}).ToList();
private IEnumerable<string> availableGrades => tabItems
.Select(i => i.StockItem.Material.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<PurchaseItem> pagedItems => filteredItems
.Skip((currentPage - 1) * pageSize)
.Take(pageSize);
protected override async Task OnInitializedAsync()
{
allItems = await PurchaseItemService.GetAllAsync();
suppliers = await SupplierService.GetAllAsync();
loading = false;
}
private void SetTab(string tab)
{
activeTab = tab;
currentPage = 1;
filterState = new();
}
private void OnFilterChanged(MaterialFilterState state)
{
filterState = state;
currentPage = 1;
}
private async Task OnSupplierChanged(PurchaseItem item, ChangeEventArgs e)
{
int? supplierId = int.TryParse(e.Value?.ToString(), out var id) && id > 0 ? id : null;
await PurchaseItemService.UpdateSupplierAsync(item.Id, supplierId);
item.SupplierId = supplierId;
item.Supplier = supplierId.HasValue ? suppliers.FirstOrDefault(s => s.Id == supplierId.Value) : null;
}
private async Task MarkOrdered(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Ordered);
item.Status = PurchaseItemStatus.Ordered;
}
private async Task MarkReceived(PurchaseItem item)
{
await PurchaseItemService.UpdateStatusAsync(item.Id, PurchaseItemStatus.Received);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
private void ConfirmDelete(PurchaseItem item)
{
itemToDelete = item;
deleteMessage = $"Are you sure you want to delete this order item ({item.StockItem.Material.DisplayName} - {ArchUnits.FormatFromInches((double)item.StockItem.LengthInches)} x{item.Quantity})?";
deleteDialog.Show();
}
private async Task DeleteConfirmed()
{
if (itemToDelete != null)
{
await PurchaseItemService.DeleteAsync(itemToDelete.Id);
allItems = await PurchaseItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private static string GetStatusBadgeClass(PurchaseItemStatus status) => status switch
{
PurchaseItemStatus.Pending => "bg-warning text-dark",
PurchaseItemStatus.Ordered => "bg-primary",
PurchaseItemStatus.Received => "bg-success",
_ => "bg-secondary"
};
private static bool Contains(string? value, string search) =>
value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase);
}
@@ -264,8 +264,8 @@ else
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)">Delete</button>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
+103 -39
View File
@@ -28,47 +28,60 @@ else if (stockItems.Count == 0)
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th>Length</th>
<th>On Hand</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in stockItems)
{
<MaterialFilter AvailableGrades="availableGrades" Value="filterState" ValueChanged="OnFilterChanged" />
@if (filteredItems.Count == 0)
{
<div class="alert alert-warning">
No stock items match your filters.
</div>
}
else
{
<table class="table table-striped table-hover">
<thead>
<tr>
<td>@item.Material.Shape.GetDisplayName()</td>
<td>@item.Material.Type</td>
<td>@item.Material.Grade</td>
<td>@item.Material.Size</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td>
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success">@item.QuantityOnHand</span>
}
else
{
<span class="badge bg-secondary">0</span>
}
</td>
<td>
<div class="d-flex gap-1">
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)">Delete</button>
</div>
</td>
<th>Shape</th>
<th>Type</th>
<th>Grade</th>
<th>Size</th>
<th>Length</th>
<th>On Hand</th>
<th style="width: 100px;">Actions</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var item in pagedItems)
{
<tr>
<td>@item.Material.Shape.GetDisplayName()</td>
<td>@item.Material.Type</td>
<td>@item.Material.Grade</td>
<td>@item.Material.Size</td>
<td>@ArchUnits.FormatFromInches((double)item.LengthInches)</td>
<td>
@if (item.QuantityOnHand > 0)
{
<span class="badge bg-success">@item.QuantityOnHand</span>
}
else
{
<span class="badge bg-secondary">0</span>
}
</td>
<td>
<div class="d-flex gap-1">
<a href="stock/@item.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(item)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="filteredItems.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
<ConfirmDialog @ref="deleteDialog"
@@ -80,9 +93,45 @@ else
@code {
private List<StockItem> stockItems = new();
private bool loading = true;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private StockItem? itemToDelete;
private string deleteMessage = "";
private MaterialFilterState filterState = new();
private List<StockItem> filteredItems => stockItems.Where(s =>
{
var m = s.Material;
if (filterState.Shape.HasValue && m.Shape != filterState.Shape.Value)
return false;
if (filterState.Type.HasValue && m.Type != filterState.Type.Value)
return false;
if (!string.IsNullOrEmpty(filterState.Grade) && m.Grade != filterState.Grade)
return false;
if (!string.IsNullOrWhiteSpace(filterState.SearchText))
{
var search = filterState.SearchText.Trim();
if (!Contains(m.Size, search)
&& !Contains(m.Grade, search)
&& !Contains(m.Description, search)
&& !Contains(m.Shape.GetDisplayName(), search)
&& !Contains(s.Name, search)
&& !Contains(s.Notes, search))
return false;
}
return true;
}).ToList();
private IEnumerable<string> availableGrades => stockItems
.Select(s => s.Material.Grade)
.Where(g => !string.IsNullOrEmpty(g))
.Distinct()
.OrderBy(g => g)!;
private IEnumerable<StockItem> pagedItems => filteredItems
.Skip((currentPage - 1) * pageSize)
.Take(pageSize);
protected override async Task OnInitializedAsync()
{
@@ -90,6 +139,12 @@ else
loading = false;
}
private void OnFilterChanged(MaterialFilterState state)
{
filterState = state;
currentPage = 1;
}
private void ConfirmDelete(StockItem item)
{
itemToDelete = item;
@@ -103,6 +158,15 @@ else
{
await StockItemService.DeleteAsync(itemToDelete.Id);
stockItems = await StockItemService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)filteredItems.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private static bool Contains(string? value, string search) =>
value != null && value.Contains(search, StringComparison.OrdinalIgnoreCase);
}
@@ -143,8 +143,8 @@ else
<td>@(offering.PartNumber ?? "-")</td>
<td>@(offering.Price.HasValue ? offering.Price.Value.ToString("C") : "-")</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)">Delete</button>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditOffering(offering)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteOffering(offering)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
@@ -27,11 +27,11 @@ else
<th>Name</th>
<th>Contact Info</th>
<th>Notes</th>
<th style="width: 160px;">Actions</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var supplier in suppliers)
@foreach (var supplier in pagedSuppliers)
{
<tr>
<td><a href="suppliers/@supplier.Id">@supplier.Name</a></td>
@@ -39,14 +39,16 @@ else
<td>@TruncateText(supplier.Notes, 50)</td>
<td>
<div class="d-flex gap-1">
<a href="suppliers/@supplier.Id" class="btn btn-sm btn-outline-primary">Edit</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(supplier)">Delete</button>
<a href="suppliers/@supplier.Id" class="btn btn-sm btn-outline-primary" title="Edit"><i class="bi bi-pencil"></i></a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(supplier)" title="Delete"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="suppliers.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
<ConfirmDialog @ref="deleteDialog"
@@ -58,10 +60,14 @@ else
@code {
private List<Supplier> suppliers = new();
private bool loading = true;
private int currentPage = 1;
private int pageSize = 25;
private ConfirmDialog deleteDialog = null!;
private Supplier? supplierToDelete;
private string deleteMessage = "";
private IEnumerable<Supplier> pagedSuppliers => suppliers.Skip((currentPage - 1) * pageSize).Take(pageSize);
protected override async Task OnInitializedAsync()
{
suppliers = await SupplierService.GetAllAsync();
@@ -81,9 +87,15 @@ else
{
await SupplierService.DeleteAsync(supplierToDelete.Id);
suppliers = await SupplierService.GetAllAsync();
var totalPages = (int)Math.Ceiling((double)suppliers.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private string? TruncateText(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
+16 -4
View File
@@ -74,11 +74,11 @@ else
<th>Name</th>
<th>Kerf Width</th>
<th>Default</th>
<th style="width: 140px;">Actions</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var tool in tools)
@foreach (var tool in pagedTools)
{
<tr>
<td>@tool.Name</td>
@@ -90,13 +90,15 @@ else
}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)">Delete</button>
<button class="btn btn-sm btn-outline-primary" @onclick="() => Edit(tool)" title="Edit"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDelete(tool)" title="Delete"><i class="bi bi-trash"></i></button>
</td>
</tr>
}
</tbody>
</table>
<Pager TotalCount="tools.Count" PageSize="pageSize" CurrentPage="currentPage" CurrentPageChanged="OnPageChanged" />
}
}
@@ -112,6 +114,10 @@ else
private bool showForm;
private bool saving;
private string? errorMessage;
private int currentPage = 1;
private int pageSize = 25;
private IEnumerable<CuttingTool> pagedTools => tools.Skip((currentPage - 1) * pageSize).Take(pageSize);
private CuttingTool formTool = new();
private CuttingTool? editingTool;
@@ -207,9 +213,15 @@ else
{
await JobService.DeleteCuttingToolAsync(toolToDelete.Id);
tools = await JobService.GetCuttingToolsAsync();
var totalPages = (int)Math.Ceiling((double)tools.Count / pageSize);
if (currentPage > totalPages && totalPages > 0)
currentPage = totalPages;
}
}
private void OnPageChanged(int page) => currentPage = page;
private string FormatKerf(decimal kerf)
{
// Show as fraction if it's a common value
@@ -0,0 +1,79 @@
@using CutList.Web.Data.Entities
<div class="row g-2 mb-3">
<div class="col-auto">
<select class="form-select form-select-sm" value="@Value.Shape" @onchange="OnShapeChanged">
<option value="">All Shapes</option>
@foreach (var shape in Enum.GetValues<MaterialShape>())
{
<option value="@shape">@shape.GetDisplayName()</option>
}
</select>
</div>
<div class="col-auto">
<select class="form-select form-select-sm" value="@Value.Type" @onchange="OnTypeChanged">
<option value="">All Types</option>
@foreach (var type in Enum.GetValues<MaterialType>())
{
<option value="@type">@type</option>
}
</select>
</div>
<div class="col-auto">
<select class="form-select form-select-sm" value="@(Value.Grade ?? "")" @onchange="OnGradeChanged">
<option value="">All Grades</option>
@foreach (var grade in AvailableGrades)
{
<option value="@grade">@grade</option>
}
</select>
</div>
<div class="col-auto">
<input type="text" class="form-control form-control-sm" placeholder="Search..." value="@Value.SearchText" @oninput="OnSearchInput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-secondary" @onclick="OnClear" title="Clear filters">
<i class="bi bi-x-lg"></i> Clear
</button>
</div>
</div>
@code {
[Parameter] public IEnumerable<string> AvailableGrades { get; set; } = Enumerable.Empty<string>();
[Parameter] public MaterialFilterState Value { get; set; } = new();
[Parameter] public EventCallback<MaterialFilterState> ValueChanged { get; set; }
private async Task OnShapeChanged(ChangeEventArgs e)
{
Value.Shape = Enum.TryParse<MaterialShape>(e.Value?.ToString(), out var shape) ? shape : null;
await ValueChanged.InvokeAsync(Value);
}
private async Task OnTypeChanged(ChangeEventArgs e)
{
Value.Type = Enum.TryParse<MaterialType>(e.Value?.ToString(), out var type) ? type : null;
await ValueChanged.InvokeAsync(Value);
}
private async Task OnGradeChanged(ChangeEventArgs e)
{
var val = e.Value?.ToString();
Value.Grade = string.IsNullOrEmpty(val) ? null : val;
await ValueChanged.InvokeAsync(Value);
}
private async Task OnSearchInput(ChangeEventArgs e)
{
Value.SearchText = e.Value?.ToString();
await ValueChanged.InvokeAsync(Value);
}
private async Task OnClear()
{
Value.Shape = null;
Value.Type = null;
Value.Grade = null;
Value.SearchText = null;
await ValueChanged.InvokeAsync(Value);
}
}
@@ -0,0 +1,11 @@
using CutList.Web.Data.Entities;
namespace CutList.Web.Components.Shared;
public class MaterialFilterState
{
public MaterialShape? Shape { get; set; }
public MaterialType? Type { get; set; }
public string? Grade { get; set; }
public string? SearchText { get; set; }
}
+109
View File
@@ -0,0 +1,109 @@
@if (TotalCount == 0)
{
return;
}
@{
var totalPages = (int)Math.Ceiling((double)TotalCount / PageSize);
var start = (CurrentPage - 1) * PageSize + 1;
var end = Math.Min(CurrentPage * PageSize, TotalCount);
}
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">Showing @start@end of @TotalCount</small>
@if (totalPages > 1)
{
<nav>
<ul class="pagination pagination-sm mb-0">
<li class="page-item @(CurrentPage == 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => SetPage(CurrentPage - 1)" disabled="@(CurrentPage == 1)">Previous</button>
</li>
@foreach (var page in GetPageWindow(totalPages))
{
@if (page == -1)
{
<li class="page-item disabled">
<span class="page-link">&hellip;</span>
</li>
}
else
{
<li class="page-item @(page == CurrentPage ? "active" : "")">
<button class="page-link" @onclick="() => SetPage(page)">@(page)</button>
</li>
}
}
<li class="page-item @(CurrentPage == totalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => SetPage(CurrentPage + 1)" disabled="@(CurrentPage == totalPages)">Next</button>
</li>
</ul>
</nav>
}
</div>
@code {
[Parameter, EditorRequired]
public int TotalCount { get; set; }
[Parameter]
public int PageSize { get; set; } = 25;
[Parameter]
public int CurrentPage { get; set; } = 1;
[Parameter]
public EventCallback<int> CurrentPageChanged { get; set; }
private async Task SetPage(int page)
{
if (page < 1 || page > (int)Math.Ceiling((double)TotalCount / PageSize))
return;
if (page == CurrentPage)
return;
CurrentPage = page;
await CurrentPageChanged.InvokeAsync(page);
}
private IEnumerable<int> GetPageWindow(int totalPages)
{
const int maxVisible = 7;
if (totalPages <= maxVisible)
{
for (var i = 1; i <= totalPages; i++)
yield return i;
yield break;
}
// Always show first page
yield return 1;
var windowStart = Math.Max(2, CurrentPage - 2);
var windowEnd = Math.Min(totalPages - 1, CurrentPage + 2);
// Adjust window to show 5 middle pages when possible
if (windowEnd - windowStart < 4)
{
if (windowStart == 2)
windowEnd = Math.Min(totalPages - 1, windowStart + 4);
else
windowStart = Math.Max(2, windowEnd - 4);
}
if (windowStart > 2)
yield return -1; // ellipsis
for (var i = windowStart; i <= windowEnd; i++)
yield return i;
if (windowEnd < totalPages - 1)
yield return -1; // ellipsis
// Always show last page
yield return totalPages;
}
}
@@ -0,0 +1,41 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using CutList.Web.DTOs;
using CutList.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace CutList.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class CatalogController : ControllerBase
{
private readonly CatalogService _catalogService;
public CatalogController(CatalogService catalogService)
{
_catalogService = catalogService;
}
[HttpGet("export")]
public async Task<IActionResult> Export()
{
var data = await _catalogService.ExportAsync();
var options = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return new JsonResult(data, options);
}
[HttpPost("import")]
public async Task<ActionResult<ImportResultDto>> Import([FromBody] CatalogData data)
{
var result = await _catalogService.ImportAsync(data);
return Ok(result);
}
}
-94
View File
@@ -1,94 +0,0 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SeedController : ControllerBase
{
private readonly ApplicationDbContext _context;
public SeedController(ApplicationDbContext context)
{
_context = context;
}
[HttpPost("alro-1018-round")]
public async Task<ActionResult> SeedAlro1018Round()
{
// Add Alro supplier if not exists
var alro = await _context.Suppliers.FirstOrDefaultAsync(s => s.Name == "Alro");
if (alro == null)
{
alro = new Supplier
{
Name = "Alro",
ContactInfo = "https://www.alro.com",
CreatedAt = DateTime.UtcNow
};
_context.Suppliers.Add(alro);
await _context.SaveChangesAsync();
}
// 1018 CF Round bar sizes from the screenshot
var sizes = new[]
{
"1/8\"",
"5/32\"",
"3/16\"",
"7/32\"",
".236\"",
"1/4\"",
"9/32\"",
"5/16\"",
"11/32\"",
"3/8\"",
".394\"",
"13/32\"",
"7/16\"",
"15/32\"",
".472\"",
"1/2\"",
"17/32\"",
"9/16\"",
".593\""
};
var created = 0;
var skipped = 0;
foreach (var size in sizes)
{
var exists = await _context.Materials
.AnyAsync(m => m.Shape == MaterialShape.RoundBar && m.Size == size && m.IsActive);
if (exists)
{
skipped++;
continue;
}
_context.Materials.Add(new Material
{
Shape = MaterialShape.RoundBar,
Size = size,
Description = "1018 Cold Finished",
CreatedAt = DateTime.UtcNow
});
created++;
}
await _context.SaveChangesAsync();
return Ok(new
{
Message = "Alro 1018 CF Round materials seeded",
SupplierId = alro.Id,
MaterialsCreated = created,
MaterialsSkipped = skipped
});
}
}
+1
View File
@@ -16,6 +16,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.3" />
</ItemGroup>
</Project>
+142
View File
@@ -0,0 +1,142 @@
namespace CutList.Web.DTOs;
public class CatalogData
{
public DateTime ExportedAt { get; set; }
public List<CatalogSupplierDto> Suppliers { get; set; } = [];
public List<CatalogCuttingToolDto> CuttingTools { get; set; } = [];
public CatalogMaterialsDto Materials { get; set; } = new();
}
public class CatalogSupplierDto
{
public string Name { get; set; } = "";
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
}
public class CatalogCuttingToolDto
{
public string Name { get; set; } = "";
public decimal KerfInches { get; set; }
public bool IsDefault { get; set; }
}
public class CatalogMaterialsDto
{
public List<CatalogAngleDto> Angles { get; set; } = [];
public List<CatalogChannelDto> Channels { get; set; } = [];
public List<CatalogFlatBarDto> FlatBars { get; set; } = [];
public List<CatalogIBeamDto> IBeams { get; set; } = [];
public List<CatalogPipeDto> Pipes { get; set; } = [];
public List<CatalogRectangularTubeDto> RectangularTubes { get; set; } = [];
public List<CatalogRoundBarDto> RoundBars { get; set; } = [];
public List<CatalogRoundTubeDto> RoundTubes { get; set; } = [];
public List<CatalogSquareBarDto> SquareBars { get; set; } = [];
public List<CatalogSquareTubeDto> SquareTubes { get; set; } = [];
}
public abstract class CatalogMaterialBaseDto
{
public string Type { get; set; } = "";
public string? Grade { get; set; }
public string Size { get; set; } = "";
public string? Description { get; set; }
public List<CatalogStockItemDto> StockItems { get; set; } = [];
}
public class CatalogAngleDto : CatalogMaterialBaseDto
{
public decimal Leg1 { get; set; }
public decimal Leg2 { get; set; }
public decimal Thickness { get; set; }
}
public class CatalogChannelDto : CatalogMaterialBaseDto
{
public decimal Height { get; set; }
public decimal Flange { get; set; }
public decimal Web { get; set; }
}
public class CatalogFlatBarDto : CatalogMaterialBaseDto
{
public decimal Width { get; set; }
public decimal Thickness { get; set; }
}
public class CatalogIBeamDto : CatalogMaterialBaseDto
{
public decimal Height { get; set; }
public decimal WeightPerFoot { get; set; }
}
public class CatalogPipeDto : CatalogMaterialBaseDto
{
public decimal NominalSize { get; set; }
public decimal Wall { get; set; }
public string? Schedule { get; set; }
}
public class CatalogRectangularTubeDto : CatalogMaterialBaseDto
{
public decimal Width { get; set; }
public decimal Height { get; set; }
public decimal Wall { get; set; }
}
public class CatalogRoundBarDto : CatalogMaterialBaseDto
{
public decimal Diameter { get; set; }
}
public class CatalogRoundTubeDto : CatalogMaterialBaseDto
{
public decimal OuterDiameter { get; set; }
public decimal Wall { get; set; }
}
public class CatalogSquareBarDto : CatalogMaterialBaseDto
{
public decimal SideLength { get; set; }
}
public class CatalogSquareTubeDto : CatalogMaterialBaseDto
{
public decimal SideLength { get; set; }
public decimal Wall { get; set; }
}
public class CatalogStockItemDto
{
public decimal LengthInches { get; set; }
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public List<CatalogSupplierOfferingDto> SupplierOfferings { get; set; } = [];
}
public class CatalogSupplierOfferingDto
{
public string SupplierName { get; set; } = "";
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
}
public class ImportResultDto
{
public int SuppliersCreated { get; set; }
public int SuppliersUpdated { get; set; }
public int CuttingToolsCreated { get; set; }
public int CuttingToolsUpdated { get; set; }
public int MaterialsCreated { get; set; }
public int MaterialsUpdated { get; set; }
public int StockItemsCreated { get; set; }
public int StockItemsUpdated { get; set; }
public int OfferingsCreated { get; set; }
public int OfferingsUpdated { get; set; }
public List<string> Errors { get; set; } = [];
public List<string> Warnings { get; set; } = [];
}
+56 -27
View File
@@ -20,6 +20,7 @@ public class ApplicationDbContext : DbContext
public DbSet<Job> Jobs => Set<Job>();
public DbSet<JobPart> JobParts => Set<JobPart>();
public DbSet<JobStock> JobStocks => Set<JobStock>();
public DbSet<PurchaseItem> PurchaseItems => Set<PurchaseItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -46,84 +47,80 @@ public class ApplicationDbContext : DbContext
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
});
// MaterialDimensions - TPH inheritance
// MaterialDimensions - TPC inheritance (each shape gets its own table, no base table)
modelBuilder.Entity<MaterialDimensions>(entity =>
{
entity.HasKey(e => e.Id);
entity.UseTpcMappingStrategy();
// 1:1 relationship with Material
entity.HasOne(e => e.Material)
.WithOne(m => m.Dimensions)
.HasForeignKey<MaterialDimensions>(e => e.MaterialId)
.OnDelete(DeleteBehavior.Cascade);
// TPH discriminator
entity.HasDiscriminator<string>("DimensionType")
.HasValue<RoundBarDimensions>("RoundBar")
.HasValue<RoundTubeDimensions>("RoundTube")
.HasValue<FlatBarDimensions>("FlatBar")
.HasValue<SquareBarDimensions>("SquareBar")
.HasValue<SquareTubeDimensions>("SquareTube")
.HasValue<RectangularTubeDimensions>("RectangularTube")
.HasValue<AngleDimensions>("Angle")
.HasValue<ChannelDimensions>("Channel")
.HasValue<IBeamDimensions>("IBeam")
.HasValue<PipeDimensions>("Pipe");
});
// Configure each dimension type's properties
modelBuilder.Entity<RoundBarDimensions>(entity =>
{
entity.ToTable("DimRoundBar");
entity.Property(e => e.Diameter).HasPrecision(10, 4);
entity.HasIndex(e => e.Diameter);
});
modelBuilder.Entity<RoundTubeDimensions>(entity =>
{
entity.ToTable("DimRoundTube");
entity.Property(e => e.OuterDiameter).HasPrecision(10, 4);
entity.Property(e => e.Wall).HasColumnName("Wall").HasPrecision(10, 4);
entity.Property(e => e.Wall).HasPrecision(10, 4);
entity.HasIndex(e => e.OuterDiameter);
});
modelBuilder.Entity<FlatBarDimensions>(entity =>
{
entity.Property(e => e.Width).HasColumnName("Width").HasPrecision(10, 4);
entity.Property(e => e.Thickness).HasColumnName("Thickness").HasPrecision(10, 4);
entity.ToTable("DimFlatBar");
entity.Property(e => e.Width).HasPrecision(10, 4);
entity.Property(e => e.Thickness).HasPrecision(10, 4);
entity.HasIndex(e => e.Width);
});
modelBuilder.Entity<SquareBarDimensions>(entity =>
{
entity.Property(e => e.Size).HasColumnName("Size").HasPrecision(10, 4);
entity.ToTable("DimSquareBar");
entity.Property(e => e.Size).HasPrecision(10, 4);
entity.HasIndex(e => e.Size);
});
modelBuilder.Entity<SquareTubeDimensions>(entity =>
{
entity.Property(e => e.Size).HasColumnName("Size").HasPrecision(10, 4);
entity.Property(e => e.Wall).HasColumnName("Wall").HasPrecision(10, 4);
entity.ToTable("DimSquareTube");
entity.Property(e => e.Size).HasPrecision(10, 4);
entity.Property(e => e.Wall).HasPrecision(10, 4);
entity.HasIndex(e => e.Size);
});
modelBuilder.Entity<RectangularTubeDimensions>(entity =>
{
entity.Property(e => e.Width).HasColumnName("Width").HasPrecision(10, 4);
entity.Property(e => e.Height).HasColumnName("Height").HasPrecision(10, 4);
entity.Property(e => e.Wall).HasColumnName("Wall").HasPrecision(10, 4);
entity.ToTable("DimRectangularTube");
entity.Property(e => e.Width).HasPrecision(10, 4);
entity.Property(e => e.Height).HasPrecision(10, 4);
entity.Property(e => e.Wall).HasPrecision(10, 4);
entity.HasIndex(e => e.Width);
});
modelBuilder.Entity<AngleDimensions>(entity =>
{
entity.ToTable("DimAngle");
entity.Property(e => e.Leg1).HasPrecision(10, 4);
entity.Property(e => e.Leg2).HasPrecision(10, 4);
entity.Property(e => e.Thickness).HasColumnName("Thickness").HasPrecision(10, 4);
entity.Property(e => e.Thickness).HasPrecision(10, 4);
entity.HasIndex(e => e.Leg1);
});
modelBuilder.Entity<ChannelDimensions>(entity =>
{
entity.Property(e => e.Height).HasColumnName("Height").HasPrecision(10, 4);
entity.ToTable("DimChannel");
entity.Property(e => e.Height).HasPrecision(10, 4);
entity.Property(e => e.Flange).HasPrecision(10, 4);
entity.Property(e => e.Web).HasPrecision(10, 4);
entity.HasIndex(e => e.Height);
@@ -131,15 +128,17 @@ public class ApplicationDbContext : DbContext
modelBuilder.Entity<IBeamDimensions>(entity =>
{
entity.Property(e => e.Height).HasColumnName("Height").HasPrecision(10, 4);
entity.ToTable("DimIBeam");
entity.Property(e => e.Height).HasPrecision(10, 4);
entity.Property(e => e.WeightPerFoot).HasPrecision(10, 4);
entity.HasIndex(e => e.Height);
});
modelBuilder.Entity<PipeDimensions>(entity =>
{
entity.ToTable("DimPipe");
entity.Property(e => e.NominalSize).HasPrecision(10, 4);
entity.Property(e => e.Wall).HasColumnName("Wall").HasPrecision(10, 4);
entity.Property(e => e.Wall).HasPrecision(10, 4);
entity.Property(e => e.Schedule).HasMaxLength(20);
entity.HasIndex(e => e.NominalSize);
});
@@ -233,6 +232,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)
@@ -281,6 +282,34 @@ public class ApplicationDbContext : DbContext
.OnDelete(DeleteBehavior.SetNull);
});
// PurchaseItem
modelBuilder.Entity<PurchaseItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.Status)
.HasMaxLength(20)
.HasConversion(
v => v.ToString(),
v => Enum.Parse<PurchaseItemStatus>(v));
entity.Property(e => e.CreatedAt).HasDefaultValueSql("GETUTCDATE()");
entity.HasOne(e => e.StockItem)
.WithMany()
.HasForeignKey(e => e.StockItemId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Supplier)
.WithMany()
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Job)
.WithMany()
.HasForeignKey(e => e.JobId)
.OnDelete(DeleteBehavior.SetNull);
});
// Seed default cutting tools
modelBuilder.Entity<CuttingTool>().HasData(
new CuttingTool { Id = 1, Name = "Bandsaw", KerfInches = 0.0625m, IsDefault = true, IsActive = true },
+5
View File
@@ -10,6 +10,11 @@ public class Job
public string? Notes { get; set; }
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;
public CuttingTool? CuttingTool { get; set; }
public ICollection<JobPart> Parts { get; set; } = new List<JobPart>();
+25
View File
@@ -0,0 +1,25 @@
namespace CutList.Web.Data.Entities;
public enum PurchaseItemStatus
{
Pending,
Ordered,
Received
}
public class PurchaseItem
{
public int Id { get; set; }
public int StockItemId { get; set; }
public int? SupplierId { get; set; }
public int Quantity { get; set; }
public int? JobId { get; set; }
public string? Notes { get; set; }
public PurchaseItemStatus Status { get; set; } = PurchaseItemStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public StockItem StockItem { get; set; } = null!;
public Supplier? Supplier { get; set; }
public Job? Job { get; set; }
}
@@ -0,0 +1,42 @@
{
"exportedAt": "2026-02-16T17:09:52.843008+00:00",
"suppliers": [
{
"name": "Alro Steel"
}
],
"cuttingTools": [
{
"name": "Bandsaw",
"kerfInches": 0.0625,
"isDefault": true
},
{
"name": "Chop Saw",
"kerfInches": 0.125,
"isDefault": false
},
{
"name": "Cold Cut Saw",
"kerfInches": 0.0625,
"isDefault": false
},
{
"name": "Hacksaw",
"kerfInches": 0.0625,
"isDefault": false
}
],
"materials": {
"angles": [],
"channels": [],
"flatBars": [],
"iBeams": [],
"pipes": [],
"rectangularTubes": [],
"roundBars": [],
"roundTubes": [],
"squareBars": [],
"squareTubes": []
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,896 @@
// <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("20260207195807_AddPurchaseItem")]
partial class AddPurchaseItem
{
/// <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<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
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,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class AddPurchaseItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StockItemId = table.Column<int>(type: "int", nullable: false),
SupplierId = table.Column<int>(type: "int", nullable: true),
Quantity = table.Column<int>(type: "int", nullable: false),
JobId = table.Column<int>(type: "int", nullable: true),
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "GETUTCDATE()"),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseItems_Jobs_JobId",
column: x => x.JobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PurchaseItems_StockItems_StockItemId",
column: x => x.StockItemId,
principalTable: "StockItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PurchaseItems_Suppliers_SupplierId",
column: x => x.SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_JobId",
table: "PurchaseItems",
column: "JobId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_StockItemId",
table: "PurchaseItems",
column: "StockItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseItems_SupplierId",
table: "PurchaseItems",
column: "SupplierId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PurchaseItems");
}
}
}
@@ -0,0 +1,899 @@
// <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("20260207201007_AddJobLockedAt")]
partial class AddJobLockedAt
{
/// <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<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,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class AddJobLockedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LockedAt",
table: "Jobs",
type: "datetime2",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LockedAt",
table: "Jobs");
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,962 @@
// <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("20260216183131_MaterialDimensionsTPHtoTPT")]
partial class MaterialDimensionsTPHtoTPT
{
/// <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<int>("MaterialId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable("MaterialDimensions");
b.UseTptMappingStrategy();
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Leg1");
b.ToTable("AngleDimensions");
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("ChannelDimensions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("FlatBarDimensions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("IBeamDimensions");
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("NominalSize");
b.ToTable("PipeDimensions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("RectangularTubeDimensions");
});
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.ToTable("RoundBarDimensions");
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("OuterDiameter");
b.ToTable("RoundTubeDimensions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("SquareBarDimensions");
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("SquareTubeDimensions");
});
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.AngleDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.AngleDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.ChannelDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.FlatBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.IBeamDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.PipeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RectangularTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RoundBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RoundTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.SquareBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.SquareTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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,353 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class MaterialDimensionsTPHtoTPT : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Create the new TPT tables first (before dropping any columns)
migrationBuilder.CreateTable(
name: "AngleDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Leg1 = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Leg2 = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Thickness = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AngleDimensions", x => x.Id);
table.ForeignKey(
name: "FK_AngleDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChannelDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Height = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Flange = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Web = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelDimensions", x => x.Id);
table.ForeignKey(
name: "FK_ChannelDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FlatBarDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Width = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Thickness = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FlatBarDimensions", x => x.Id);
table.ForeignKey(
name: "FK_FlatBarDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "IBeamDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Height = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
WeightPerFoot = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_IBeamDimensions", x => x.Id);
table.ForeignKey(
name: "FK_IBeamDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PipeDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
NominalSize = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Wall = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: true),
Schedule = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PipeDimensions", x => x.Id);
table.ForeignKey(
name: "FK_PipeDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RectangularTubeDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Width = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Height = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Wall = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RectangularTubeDimensions", x => x.Id);
table.ForeignKey(
name: "FK_RectangularTubeDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RoundBarDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Diameter = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoundBarDimensions", x => x.Id);
table.ForeignKey(
name: "FK_RoundBarDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RoundTubeDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
OuterDiameter = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Wall = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RoundTubeDimensions", x => x.Id);
table.ForeignKey(
name: "FK_RoundTubeDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SquareBarDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Size = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SquareBarDimensions", x => x.Id);
table.ForeignKey(
name: "FK_SquareBarDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SquareTubeDimensions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false),
Size = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false),
Wall = table.Column<decimal>(type: "decimal(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SquareTubeDimensions", x => x.Id);
table.ForeignKey(
name: "FK_SquareTubeDimensions_MaterialDimensions_Id",
column: x => x.Id,
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// 2. Migrate existing data from the TPH table into the new TPT tables
migrationBuilder.Sql(@"
INSERT INTO RoundBarDimensions (Id, Diameter)
SELECT Id, ISNULL(Diameter, 0) FROM MaterialDimensions WHERE DimensionType = 'RoundBar';
INSERT INTO RoundTubeDimensions (Id, OuterDiameter, Wall)
SELECT Id, ISNULL(OuterDiameter, 0), ISNULL(Wall, 0) FROM MaterialDimensions WHERE DimensionType = 'RoundTube';
INSERT INTO FlatBarDimensions (Id, Width, Thickness)
SELECT Id, ISNULL(Width, 0), ISNULL(Thickness, 0) FROM MaterialDimensions WHERE DimensionType = 'FlatBar';
INSERT INTO SquareBarDimensions (Id, Size)
SELECT Id, ISNULL(Size, 0) FROM MaterialDimensions WHERE DimensionType = 'SquareBar';
INSERT INTO SquareTubeDimensions (Id, Size, Wall)
SELECT Id, ISNULL(Size, 0), ISNULL(Wall, 0) FROM MaterialDimensions WHERE DimensionType = 'SquareTube';
INSERT INTO RectangularTubeDimensions (Id, Width, Height, Wall)
SELECT Id, ISNULL(Width, 0), ISNULL(Height, 0), ISNULL(Wall, 0) FROM MaterialDimensions WHERE DimensionType = 'RectangularTube';
INSERT INTO AngleDimensions (Id, Leg1, Leg2, Thickness)
SELECT Id, ISNULL(Leg1, 0), ISNULL(Leg2, 0), ISNULL(Thickness, 0) FROM MaterialDimensions WHERE DimensionType = 'Angle';
INSERT INTO ChannelDimensions (Id, Height, Flange, Web)
SELECT Id, ISNULL(Height, 0), ISNULL(Flange, 0), ISNULL(Web, 0) FROM MaterialDimensions WHERE DimensionType = 'Channel';
INSERT INTO IBeamDimensions (Id, Height, WeightPerFoot)
SELECT Id, ISNULL(Height, 0), ISNULL(WeightPerFoot, 0) FROM MaterialDimensions WHERE DimensionType = 'IBeam';
INSERT INTO PipeDimensions (Id, NominalSize, Wall, Schedule)
SELECT Id, ISNULL(NominalSize, 0), Wall, Schedule FROM MaterialDimensions WHERE DimensionType = 'Pipe';
");
// 3. Now drop the old TPH columns and indexes
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_Diameter",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_Height",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_Leg1",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_NominalSize",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_OuterDiameter",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_Size",
table: "MaterialDimensions");
migrationBuilder.DropIndex(
name: "IX_MaterialDimensions_Width",
table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Diameter", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "DimensionType", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Flange", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Height", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Leg1", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Leg2", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "NominalSize", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "OuterDiameter", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Schedule", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Size", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Thickness", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Wall", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Web", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "WeightPerFoot", table: "MaterialDimensions");
migrationBuilder.DropColumn(name: "Width", table: "MaterialDimensions");
// 4. Create indexes on the new tables
migrationBuilder.CreateIndex(name: "IX_AngleDimensions_Leg1", table: "AngleDimensions", column: "Leg1");
migrationBuilder.CreateIndex(name: "IX_ChannelDimensions_Height", table: "ChannelDimensions", column: "Height");
migrationBuilder.CreateIndex(name: "IX_FlatBarDimensions_Width", table: "FlatBarDimensions", column: "Width");
migrationBuilder.CreateIndex(name: "IX_IBeamDimensions_Height", table: "IBeamDimensions", column: "Height");
migrationBuilder.CreateIndex(name: "IX_PipeDimensions_NominalSize", table: "PipeDimensions", column: "NominalSize");
migrationBuilder.CreateIndex(name: "IX_RectangularTubeDimensions_Width", table: "RectangularTubeDimensions", column: "Width");
migrationBuilder.CreateIndex(name: "IX_RoundBarDimensions_Diameter", table: "RoundBarDimensions", column: "Diameter");
migrationBuilder.CreateIndex(name: "IX_RoundTubeDimensions_OuterDiameter", table: "RoundTubeDimensions", column: "OuterDiameter");
migrationBuilder.CreateIndex(name: "IX_SquareBarDimensions_Size", table: "SquareBarDimensions", column: "Size");
migrationBuilder.CreateIndex(name: "IX_SquareTubeDimensions_Size", table: "SquareTubeDimensions", column: "Size");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Re-add the TPH columns
migrationBuilder.AddColumn<string>(name: "DimensionType", table: "MaterialDimensions", type: "nvarchar(21)", maxLength: 21, nullable: false, defaultValue: "");
migrationBuilder.AddColumn<decimal>(name: "Diameter", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Flange", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Height", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Leg1", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Leg2", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "NominalSize", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "OuterDiameter", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<string>(name: "Schedule", table: "MaterialDimensions", type: "nvarchar(20)", maxLength: 20, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Size", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Thickness", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Wall", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Web", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "WeightPerFoot", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
migrationBuilder.AddColumn<decimal>(name: "Width", table: "MaterialDimensions", type: "decimal(10,4)", precision: 10, scale: 4, nullable: true);
// Migrate data back to TPH
migrationBuilder.Sql(@"
UPDATE md SET DimensionType = 'RoundBar', Diameter = rb.Diameter FROM MaterialDimensions md INNER JOIN RoundBarDimensions rb ON md.Id = rb.Id;
UPDATE md SET DimensionType = 'RoundTube', OuterDiameter = rt.OuterDiameter, Wall = rt.Wall FROM MaterialDimensions md INNER JOIN RoundTubeDimensions rt ON md.Id = rt.Id;
UPDATE md SET DimensionType = 'FlatBar', Width = fb.Width, Thickness = fb.Thickness FROM MaterialDimensions md INNER JOIN FlatBarDimensions fb ON md.Id = fb.Id;
UPDATE md SET DimensionType = 'SquareBar', Size = sb.Size FROM MaterialDimensions md INNER JOIN SquareBarDimensions sb ON md.Id = sb.Id;
UPDATE md SET DimensionType = 'SquareTube', Size = st.Size, Wall = st.Wall FROM MaterialDimensions md INNER JOIN SquareTubeDimensions st ON md.Id = st.Id;
UPDATE md SET DimensionType = 'RectangularTube', Width = rt.Width, Height = rt.Height, Wall = rt.Wall FROM MaterialDimensions md INNER JOIN RectangularTubeDimensions rt ON md.Id = rt.Id;
UPDATE md SET DimensionType = 'Angle', Leg1 = a.Leg1, Leg2 = a.Leg2, Thickness = a.Thickness FROM MaterialDimensions md INNER JOIN AngleDimensions a ON md.Id = a.Id;
UPDATE md SET DimensionType = 'Channel', Height = c.Height, Flange = c.Flange, Web = c.Web FROM MaterialDimensions md INNER JOIN ChannelDimensions c ON md.Id = c.Id;
UPDATE md SET DimensionType = 'IBeam', Height = ib.Height, WeightPerFoot = ib.WeightPerFoot FROM MaterialDimensions md INNER JOIN IBeamDimensions ib ON md.Id = ib.Id;
UPDATE md SET DimensionType = 'Pipe', NominalSize = p.NominalSize, Wall = p.Wall, Schedule = p.Schedule FROM MaterialDimensions md INNER JOIN PipeDimensions p ON md.Id = p.Id;
");
// Drop TPT tables
migrationBuilder.DropTable(name: "AngleDimensions");
migrationBuilder.DropTable(name: "ChannelDimensions");
migrationBuilder.DropTable(name: "FlatBarDimensions");
migrationBuilder.DropTable(name: "IBeamDimensions");
migrationBuilder.DropTable(name: "PipeDimensions");
migrationBuilder.DropTable(name: "RectangularTubeDimensions");
migrationBuilder.DropTable(name: "RoundBarDimensions");
migrationBuilder.DropTable(name: "RoundTubeDimensions");
migrationBuilder.DropTable(name: "SquareBarDimensions");
migrationBuilder.DropTable(name: "SquareTubeDimensions");
// Re-create TPH indexes
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_Diameter", table: "MaterialDimensions", column: "Diameter");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_Height", table: "MaterialDimensions", column: "Height");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_Leg1", table: "MaterialDimensions", column: "Leg1");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_NominalSize", table: "MaterialDimensions", column: "NominalSize");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_OuterDiameter", table: "MaterialDimensions", column: "OuterDiameter");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_Size", table: "MaterialDimensions", column: "Size");
migrationBuilder.CreateIndex(name: "IX_MaterialDimensions_Width", table: "MaterialDimensions", column: "Width");
}
}
}
@@ -0,0 +1,962 @@
// <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("20260216190925_RenameDimensionTables")]
partial class RenameDimensionTables
{
/// <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<int>("MaterialId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable("DimBase", (string)null);
b.UseTptMappingStrategy();
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Leg1");
b.ToTable("DimAngle", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimChannel", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimFlatBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimIBeam", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("NominalSize");
b.ToTable("DimPipe", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimRectangularTube", (string)null);
});
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.ToTable("DimRoundBar", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("OuterDiameter");
b.ToTable("DimRoundTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareTube", (string)null);
});
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.AngleDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.AngleDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.ChannelDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.FlatBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.IBeamDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.PipeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RectangularTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RoundBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.RoundTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.SquareBarDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasOne("CutList.Web.Data.Entities.MaterialDimensions", null)
.WithOne()
.HasForeignKey("CutList.Web.Data.Entities.SquareTubeDimensions", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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,678 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class RenameDimensionTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AngleDimensions_MaterialDimensions_Id",
table: "AngleDimensions");
migrationBuilder.DropForeignKey(
name: "FK_ChannelDimensions_MaterialDimensions_Id",
table: "ChannelDimensions");
migrationBuilder.DropForeignKey(
name: "FK_FlatBarDimensions_MaterialDimensions_Id",
table: "FlatBarDimensions");
migrationBuilder.DropForeignKey(
name: "FK_IBeamDimensions_MaterialDimensions_Id",
table: "IBeamDimensions");
migrationBuilder.DropForeignKey(
name: "FK_MaterialDimensions_Materials_MaterialId",
table: "MaterialDimensions");
migrationBuilder.DropForeignKey(
name: "FK_PipeDimensions_MaterialDimensions_Id",
table: "PipeDimensions");
migrationBuilder.DropForeignKey(
name: "FK_RectangularTubeDimensions_MaterialDimensions_Id",
table: "RectangularTubeDimensions");
migrationBuilder.DropForeignKey(
name: "FK_RoundBarDimensions_MaterialDimensions_Id",
table: "RoundBarDimensions");
migrationBuilder.DropForeignKey(
name: "FK_RoundTubeDimensions_MaterialDimensions_Id",
table: "RoundTubeDimensions");
migrationBuilder.DropForeignKey(
name: "FK_SquareBarDimensions_MaterialDimensions_Id",
table: "SquareBarDimensions");
migrationBuilder.DropForeignKey(
name: "FK_SquareTubeDimensions_MaterialDimensions_Id",
table: "SquareTubeDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_SquareTubeDimensions",
table: "SquareTubeDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_SquareBarDimensions",
table: "SquareBarDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_RoundTubeDimensions",
table: "RoundTubeDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_RoundBarDimensions",
table: "RoundBarDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_RectangularTubeDimensions",
table: "RectangularTubeDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_PipeDimensions",
table: "PipeDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_MaterialDimensions",
table: "MaterialDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_IBeamDimensions",
table: "IBeamDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_FlatBarDimensions",
table: "FlatBarDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_ChannelDimensions",
table: "ChannelDimensions");
migrationBuilder.DropPrimaryKey(
name: "PK_AngleDimensions",
table: "AngleDimensions");
migrationBuilder.RenameTable(
name: "SquareTubeDimensions",
newName: "DimSquareTube");
migrationBuilder.RenameTable(
name: "SquareBarDimensions",
newName: "DimSquareBar");
migrationBuilder.RenameTable(
name: "RoundTubeDimensions",
newName: "DimRoundTube");
migrationBuilder.RenameTable(
name: "RoundBarDimensions",
newName: "DimRoundBar");
migrationBuilder.RenameTable(
name: "RectangularTubeDimensions",
newName: "DimRectangularTube");
migrationBuilder.RenameTable(
name: "PipeDimensions",
newName: "DimPipe");
migrationBuilder.RenameTable(
name: "MaterialDimensions",
newName: "DimBase");
migrationBuilder.RenameTable(
name: "IBeamDimensions",
newName: "DimIBeam");
migrationBuilder.RenameTable(
name: "FlatBarDimensions",
newName: "DimFlatBar");
migrationBuilder.RenameTable(
name: "ChannelDimensions",
newName: "DimChannel");
migrationBuilder.RenameTable(
name: "AngleDimensions",
newName: "DimAngle");
migrationBuilder.RenameIndex(
name: "IX_SquareTubeDimensions_Size",
table: "DimSquareTube",
newName: "IX_DimSquareTube_Size");
migrationBuilder.RenameIndex(
name: "IX_SquareBarDimensions_Size",
table: "DimSquareBar",
newName: "IX_DimSquareBar_Size");
migrationBuilder.RenameIndex(
name: "IX_RoundTubeDimensions_OuterDiameter",
table: "DimRoundTube",
newName: "IX_DimRoundTube_OuterDiameter");
migrationBuilder.RenameIndex(
name: "IX_RoundBarDimensions_Diameter",
table: "DimRoundBar",
newName: "IX_DimRoundBar_Diameter");
migrationBuilder.RenameIndex(
name: "IX_RectangularTubeDimensions_Width",
table: "DimRectangularTube",
newName: "IX_DimRectangularTube_Width");
migrationBuilder.RenameIndex(
name: "IX_PipeDimensions_NominalSize",
table: "DimPipe",
newName: "IX_DimPipe_NominalSize");
migrationBuilder.RenameIndex(
name: "IX_MaterialDimensions_MaterialId",
table: "DimBase",
newName: "IX_DimBase_MaterialId");
migrationBuilder.RenameIndex(
name: "IX_IBeamDimensions_Height",
table: "DimIBeam",
newName: "IX_DimIBeam_Height");
migrationBuilder.RenameIndex(
name: "IX_FlatBarDimensions_Width",
table: "DimFlatBar",
newName: "IX_DimFlatBar_Width");
migrationBuilder.RenameIndex(
name: "IX_ChannelDimensions_Height",
table: "DimChannel",
newName: "IX_DimChannel_Height");
migrationBuilder.RenameIndex(
name: "IX_AngleDimensions_Leg1",
table: "DimAngle",
newName: "IX_DimAngle_Leg1");
migrationBuilder.AddPrimaryKey(
name: "PK_DimSquareTube",
table: "DimSquareTube",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimSquareBar",
table: "DimSquareBar",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimRoundTube",
table: "DimRoundTube",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimRoundBar",
table: "DimRoundBar",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimRectangularTube",
table: "DimRectangularTube",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimPipe",
table: "DimPipe",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimBase",
table: "DimBase",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimIBeam",
table: "DimIBeam",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimFlatBar",
table: "DimFlatBar",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimChannel",
table: "DimChannel",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_DimAngle",
table: "DimAngle",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_DimAngle_DimBase_Id",
table: "DimAngle",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimBase_Materials_MaterialId",
table: "DimBase",
column: "MaterialId",
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimChannel_DimBase_Id",
table: "DimChannel",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimFlatBar_DimBase_Id",
table: "DimFlatBar",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimIBeam_DimBase_Id",
table: "DimIBeam",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimPipe_DimBase_Id",
table: "DimPipe",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimRectangularTube_DimBase_Id",
table: "DimRectangularTube",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimRoundBar_DimBase_Id",
table: "DimRoundBar",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimRoundTube_DimBase_Id",
table: "DimRoundTube",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimSquareBar_DimBase_Id",
table: "DimSquareBar",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_DimSquareTube_DimBase_Id",
table: "DimSquareTube",
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_DimAngle_DimBase_Id",
table: "DimAngle");
migrationBuilder.DropForeignKey(
name: "FK_DimBase_Materials_MaterialId",
table: "DimBase");
migrationBuilder.DropForeignKey(
name: "FK_DimChannel_DimBase_Id",
table: "DimChannel");
migrationBuilder.DropForeignKey(
name: "FK_DimFlatBar_DimBase_Id",
table: "DimFlatBar");
migrationBuilder.DropForeignKey(
name: "FK_DimIBeam_DimBase_Id",
table: "DimIBeam");
migrationBuilder.DropForeignKey(
name: "FK_DimPipe_DimBase_Id",
table: "DimPipe");
migrationBuilder.DropForeignKey(
name: "FK_DimRectangularTube_DimBase_Id",
table: "DimRectangularTube");
migrationBuilder.DropForeignKey(
name: "FK_DimRoundBar_DimBase_Id",
table: "DimRoundBar");
migrationBuilder.DropForeignKey(
name: "FK_DimRoundTube_DimBase_Id",
table: "DimRoundTube");
migrationBuilder.DropForeignKey(
name: "FK_DimSquareBar_DimBase_Id",
table: "DimSquareBar");
migrationBuilder.DropForeignKey(
name: "FK_DimSquareTube_DimBase_Id",
table: "DimSquareTube");
migrationBuilder.DropPrimaryKey(
name: "PK_DimSquareTube",
table: "DimSquareTube");
migrationBuilder.DropPrimaryKey(
name: "PK_DimSquareBar",
table: "DimSquareBar");
migrationBuilder.DropPrimaryKey(
name: "PK_DimRoundTube",
table: "DimRoundTube");
migrationBuilder.DropPrimaryKey(
name: "PK_DimRoundBar",
table: "DimRoundBar");
migrationBuilder.DropPrimaryKey(
name: "PK_DimRectangularTube",
table: "DimRectangularTube");
migrationBuilder.DropPrimaryKey(
name: "PK_DimPipe",
table: "DimPipe");
migrationBuilder.DropPrimaryKey(
name: "PK_DimIBeam",
table: "DimIBeam");
migrationBuilder.DropPrimaryKey(
name: "PK_DimFlatBar",
table: "DimFlatBar");
migrationBuilder.DropPrimaryKey(
name: "PK_DimChannel",
table: "DimChannel");
migrationBuilder.DropPrimaryKey(
name: "PK_DimBase",
table: "DimBase");
migrationBuilder.DropPrimaryKey(
name: "PK_DimAngle",
table: "DimAngle");
migrationBuilder.RenameTable(
name: "DimSquareTube",
newName: "SquareTubeDimensions");
migrationBuilder.RenameTable(
name: "DimSquareBar",
newName: "SquareBarDimensions");
migrationBuilder.RenameTable(
name: "DimRoundTube",
newName: "RoundTubeDimensions");
migrationBuilder.RenameTable(
name: "DimRoundBar",
newName: "RoundBarDimensions");
migrationBuilder.RenameTable(
name: "DimRectangularTube",
newName: "RectangularTubeDimensions");
migrationBuilder.RenameTable(
name: "DimPipe",
newName: "PipeDimensions");
migrationBuilder.RenameTable(
name: "DimIBeam",
newName: "IBeamDimensions");
migrationBuilder.RenameTable(
name: "DimFlatBar",
newName: "FlatBarDimensions");
migrationBuilder.RenameTable(
name: "DimChannel",
newName: "ChannelDimensions");
migrationBuilder.RenameTable(
name: "DimBase",
newName: "MaterialDimensions");
migrationBuilder.RenameTable(
name: "DimAngle",
newName: "AngleDimensions");
migrationBuilder.RenameIndex(
name: "IX_DimSquareTube_Size",
table: "SquareTubeDimensions",
newName: "IX_SquareTubeDimensions_Size");
migrationBuilder.RenameIndex(
name: "IX_DimSquareBar_Size",
table: "SquareBarDimensions",
newName: "IX_SquareBarDimensions_Size");
migrationBuilder.RenameIndex(
name: "IX_DimRoundTube_OuterDiameter",
table: "RoundTubeDimensions",
newName: "IX_RoundTubeDimensions_OuterDiameter");
migrationBuilder.RenameIndex(
name: "IX_DimRoundBar_Diameter",
table: "RoundBarDimensions",
newName: "IX_RoundBarDimensions_Diameter");
migrationBuilder.RenameIndex(
name: "IX_DimRectangularTube_Width",
table: "RectangularTubeDimensions",
newName: "IX_RectangularTubeDimensions_Width");
migrationBuilder.RenameIndex(
name: "IX_DimPipe_NominalSize",
table: "PipeDimensions",
newName: "IX_PipeDimensions_NominalSize");
migrationBuilder.RenameIndex(
name: "IX_DimIBeam_Height",
table: "IBeamDimensions",
newName: "IX_IBeamDimensions_Height");
migrationBuilder.RenameIndex(
name: "IX_DimFlatBar_Width",
table: "FlatBarDimensions",
newName: "IX_FlatBarDimensions_Width");
migrationBuilder.RenameIndex(
name: "IX_DimChannel_Height",
table: "ChannelDimensions",
newName: "IX_ChannelDimensions_Height");
migrationBuilder.RenameIndex(
name: "IX_DimBase_MaterialId",
table: "MaterialDimensions",
newName: "IX_MaterialDimensions_MaterialId");
migrationBuilder.RenameIndex(
name: "IX_DimAngle_Leg1",
table: "AngleDimensions",
newName: "IX_AngleDimensions_Leg1");
migrationBuilder.AddPrimaryKey(
name: "PK_SquareTubeDimensions",
table: "SquareTubeDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_SquareBarDimensions",
table: "SquareBarDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_RoundTubeDimensions",
table: "RoundTubeDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_RoundBarDimensions",
table: "RoundBarDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_RectangularTubeDimensions",
table: "RectangularTubeDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_PipeDimensions",
table: "PipeDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_IBeamDimensions",
table: "IBeamDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_FlatBarDimensions",
table: "FlatBarDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_ChannelDimensions",
table: "ChannelDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_MaterialDimensions",
table: "MaterialDimensions",
column: "Id");
migrationBuilder.AddPrimaryKey(
name: "PK_AngleDimensions",
table: "AngleDimensions",
column: "Id");
migrationBuilder.AddForeignKey(
name: "FK_AngleDimensions_MaterialDimensions_Id",
table: "AngleDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ChannelDimensions_MaterialDimensions_Id",
table: "ChannelDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_FlatBarDimensions_MaterialDimensions_Id",
table: "FlatBarDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_IBeamDimensions_MaterialDimensions_Id",
table: "IBeamDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MaterialDimensions_Materials_MaterialId",
table: "MaterialDimensions",
column: "MaterialId",
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_PipeDimensions_MaterialDimensions_Id",
table: "PipeDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RectangularTubeDimensions_MaterialDimensions_Id",
table: "RectangularTubeDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RoundBarDimensions_MaterialDimensions_Id",
table: "RoundBarDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_RoundTubeDimensions_MaterialDimensions_Id",
table: "RoundTubeDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SquareBarDimensions_MaterialDimensions_Id",
table: "SquareBarDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SquareTubeDimensions_MaterialDimensions_Id",
table: "SquareTubeDimensions",
column: "Id",
principalTable: "MaterialDimensions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,875 @@
// <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("20260216191345_DimensionsTPTtoTPC")]
partial class DimensionsTPTtoTPC
{
/// <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.HasSequence("MaterialDimensionsSequence");
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")
.HasDefaultValueSql("NEXT VALUE FOR [MaterialDimensionsSequence]");
SqlServerPropertyBuilderExtensions.UseSequence(b.Property<int>("Id"));
b.Property<int>("MaterialId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable((string)null);
b.UseTpcMappingStrategy();
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Leg1");
b.ToTable("DimAngle", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimChannel", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimFlatBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Height");
b.ToTable("DimIBeam", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("NominalSize");
b.ToTable("DimPipe", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.ToTable("DimRectangularTube", (string)null);
});
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.ToTable("DimRoundBar", (string)null);
});
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")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("OuterDiameter");
b.ToTable("DimRoundTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
{
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.ToTable("DimSquareTube", (string)null);
});
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,172 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CutList.Web.Migrations
{
/// <inheritdoc />
public partial class DimensionsTPTtoTPC : Migration
{
private static readonly string[] DimTables =
[
"DimAngle", "DimChannel", "DimFlatBar", "DimIBeam", "DimPipe",
"DimRectangularTube", "DimRoundBar", "DimRoundTube", "DimSquareBar", "DimSquareTube"
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Drop FKs from shape tables to DimBase
foreach (var table in DimTables)
{
migrationBuilder.DropForeignKey(
name: $"FK_{table}_DimBase_Id",
table: table);
}
// 2. Add MaterialId column to each shape table (nullable initially)
foreach (var table in DimTables)
{
migrationBuilder.AddColumn<int>(
name: "MaterialId",
table: table,
type: "int",
nullable: true);
}
// 3. Copy MaterialId from DimBase into each shape table
foreach (var table in DimTables)
{
migrationBuilder.Sql(
$"UPDATE t SET t.MaterialId = b.MaterialId FROM [{table}] t INNER JOIN [DimBase] b ON t.Id = b.Id");
}
// 4. Make MaterialId non-nullable now that data is populated
foreach (var table in DimTables)
{
migrationBuilder.AlterColumn<int>(
name: "MaterialId",
table: table,
type: "int",
nullable: false,
oldClrType: typeof(int),
oldNullable: true);
}
// 5. Drop DimBase
migrationBuilder.DropTable(name: "DimBase");
// 6. Create shared sequence for unique IDs across all shape tables
migrationBuilder.CreateSequence(name: "MaterialDimensionsSequence");
// 7. Switch Id columns to use the sequence
foreach (var table in DimTables)
{
migrationBuilder.AlterColumn<int>(
name: "Id",
table: table,
type: "int",
nullable: false,
defaultValueSql: "NEXT VALUE FOR [MaterialDimensionsSequence]",
oldClrType: typeof(int),
oldType: "int");
}
// 8. Create indexes and FKs for MaterialId on each shape table
foreach (var table in DimTables)
{
migrationBuilder.CreateIndex(
name: $"IX_{table}_MaterialId",
table: table,
column: "MaterialId",
unique: true);
migrationBuilder.AddForeignKey(
name: $"FK_{table}_Materials_MaterialId",
table: table,
column: "MaterialId",
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Drop FKs and indexes from shape tables
foreach (var table in DimTables)
{
migrationBuilder.DropForeignKey(
name: $"FK_{table}_Materials_MaterialId",
table: table);
migrationBuilder.DropIndex(
name: $"IX_{table}_MaterialId",
table: table);
}
// Remove sequence from Id columns
foreach (var table in DimTables)
{
migrationBuilder.AlterColumn<int>(
name: "Id",
table: table,
type: "int",
nullable: false,
oldClrType: typeof(int),
oldType: "int",
oldDefaultValueSql: "NEXT VALUE FOR [MaterialDimensionsSequence]");
}
migrationBuilder.DropSequence(name: "MaterialDimensionsSequence");
// Re-create DimBase
migrationBuilder.CreateTable(
name: "DimBase",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
MaterialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DimBase", x => x.Id);
table.ForeignKey(
name: "FK_DimBase_Materials_MaterialId",
column: x => x.MaterialId,
principalTable: "Materials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DimBase_MaterialId",
table: "DimBase",
column: "MaterialId",
unique: true);
// Copy data back to DimBase from all shape tables
foreach (var table in DimTables)
{
migrationBuilder.Sql(
$"SET IDENTITY_INSERT [DimBase] ON; INSERT INTO [DimBase] (Id, MaterialId) SELECT Id, MaterialId FROM [{table}]; SET IDENTITY_INSERT [DimBase] OFF;");
}
// Re-add FKs from shape tables to DimBase and drop MaterialId
foreach (var table in DimTables)
{
migrationBuilder.AddForeignKey(
name: $"FK_{table}_DimBase_Id",
table: table,
column: "Id",
principalTable: "DimBase",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.DropColumn(name: "MaterialId", table: table);
}
}
}
}
@@ -22,6 +22,8 @@ namespace CutList.Web.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.HasSequence("MaterialDimensionsSequence");
modelBuilder.Entity("CutList.Web.Data.Entities.CuttingTool", b =>
{
b.Property<int>("Id")
@@ -109,6 +111,9 @@ namespace CutList.Web.Migrations
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTime?>("LockedAt")
.HasColumnType("datetime2");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
@@ -116,6 +121,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");
@@ -265,14 +276,10 @@ namespace CutList.Web.Migrations
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
.HasColumnType("int")
.HasDefaultValueSql("NEXT VALUE FOR [MaterialDimensionsSequence]");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("DimensionType")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("nvarchar(21)");
SqlServerPropertyBuilderExtensions.UseSequence(b.Property<int>("Id"));
b.Property<int>("MaterialId")
.HasColumnType("int");
@@ -282,11 +289,57 @@ namespace CutList.Web.Migrations
b.HasIndex("MaterialId")
.IsUnique();
b.ToTable("MaterialDimensions");
b.ToTable((string)null);
b.HasDiscriminator<string>("DimensionType").HasValue("MaterialDimensions");
b.UseTpcMappingStrategy();
});
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 =>
@@ -470,14 +523,12 @@ namespace CutList.Web.Migrations
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Thickness")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Thickness");
.HasColumnType("decimal(10,4)");
b.HasIndex("Leg1");
b.HasDiscriminator().HasValue("Angle");
b.ToTable("DimAngle", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.ChannelDimensions", b =>
@@ -489,10 +540,8 @@ namespace CutList.Web.Migrations
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Web")
.HasPrecision(10, 4)
@@ -500,7 +549,7 @@ namespace CutList.Web.Migrations
b.HasIndex("Height");
b.HasDiscriminator().HasValue("Channel");
b.ToTable("DimChannel", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.FlatBarDimensions", b =>
@@ -508,20 +557,16 @@ namespace CutList.Web.Migrations
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Thickness")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Thickness");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Width");
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.HasDiscriminator().HasValue("FlatBar");
b.ToTable("DimFlatBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.IBeamDimensions", b =>
@@ -529,10 +574,8 @@ namespace CutList.Web.Migrations
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("WeightPerFoot")
.HasPrecision(10, 4)
@@ -540,7 +583,7 @@ namespace CutList.Web.Migrations
b.HasIndex("Height");
b.HasDiscriminator().HasValue("IBeam");
b.ToTable("DimIBeam", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.PipeDimensions", b =>
@@ -556,14 +599,12 @@ namespace CutList.Web.Migrations
.HasColumnType("nvarchar(20)");
b.Property<decimal?>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
.HasColumnType("decimal(10,4)");
b.HasIndex("NominalSize");
b.HasDiscriminator().HasValue("Pipe");
b.ToTable("DimPipe", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RectangularTubeDimensions", b =>
@@ -571,26 +612,20 @@ namespace CutList.Web.Migrations
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Height")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Height");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Width")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Width");
.HasColumnType("decimal(10,4)");
b.HasIndex("Width");
b.HasDiscriminator().HasValue("RectangularTube");
b.ToTable("DimRectangularTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundBarDimensions", b =>
@@ -603,7 +638,7 @@ namespace CutList.Web.Migrations
b.HasIndex("Diameter");
b.HasDiscriminator().HasValue("RoundBar");
b.ToTable("DimRoundBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.RoundTubeDimensions", b =>
@@ -615,14 +650,12 @@ namespace CutList.Web.Migrations
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
.HasColumnType("decimal(10,4)");
b.HasIndex("OuterDiameter");
b.HasDiscriminator().HasValue("RoundTube");
b.ToTable("DimRoundTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareBarDimensions", b =>
@@ -630,14 +663,12 @@ namespace CutList.Web.Migrations
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Size");
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.HasDiscriminator().HasValue("SquareBar");
b.ToTable("DimSquareBar", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.SquareTubeDimensions", b =>
@@ -645,20 +676,16 @@ namespace CutList.Web.Migrations
b.HasBaseType("CutList.Web.Data.Entities.MaterialDimensions");
b.Property<decimal>("Size")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Size");
.HasColumnType("decimal(10,4)");
b.Property<decimal>("Wall")
.ValueGeneratedOnUpdateSometimes()
.HasPrecision(10, 4)
.HasColumnType("decimal(10,4)")
.HasColumnName("Wall");
.HasColumnType("decimal(10,4)");
b.HasIndex("Size");
b.HasDiscriminator().HasValue("SquareTube");
b.ToTable("DimSquareTube", (string)null);
});
modelBuilder.Entity("CutList.Web.Data.Entities.Job", b =>
@@ -727,6 +754,31 @@ namespace CutList.Web.Migrations
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")
+11 -1
View File
@@ -10,6 +10,9 @@ builder.Services.AddControllers();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add Entity Framework
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
@@ -21,11 +24,18 @@ builder.Services.AddScoped<StockItemService>();
builder.Services.AddScoped<JobService>();
builder.Services.AddScoped<CutListPackingService>();
builder.Services.AddScoped<ReportService>();
builder.Services.AddScoped<PurchaseItemService>();
builder.Services.AddScoped<CatalogService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
+517
View File
@@ -0,0 +1,517 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using CutList.Web.DTOs;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class CatalogService
{
private readonly ApplicationDbContext _context;
private readonly MaterialService _materialService;
public CatalogService(ApplicationDbContext context, MaterialService materialService)
{
_context = context;
_materialService = materialService;
}
public async Task<CatalogData> ExportAsync()
{
var suppliers = await _context.Suppliers
.Where(s => s.IsActive)
.OrderBy(s => s.Name)
.AsNoTracking()
.ToListAsync();
var cuttingTools = await _context.CuttingTools
.Where(t => t.IsActive)
.OrderBy(t => t.Name)
.AsNoTracking()
.ToListAsync();
var materials = await _context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems.Where(s => s.IsActive))
.ThenInclude(s => s.SupplierOfferings.Where(o => o.IsActive))
.Where(m => m.IsActive)
.OrderBy(m => m.Shape).ThenBy(m => m.SortOrder)
.AsNoTracking()
.ToListAsync();
var grouped = materials.GroupBy(m => m.Shape);
var materialsDto = new CatalogMaterialsDto();
foreach (var group in grouped)
{
foreach (var m in group)
{
var stockItems = MapStockItems(m, suppliers);
switch (m.Shape)
{
case MaterialShape.Angle when m.Dimensions is AngleDimensions d:
materialsDto.Angles.Add(new CatalogAngleDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Leg1 = d.Leg1, Leg2 = d.Leg2, Thickness = d.Thickness,
StockItems = stockItems
});
break;
case MaterialShape.Channel when m.Dimensions is ChannelDimensions d:
materialsDto.Channels.Add(new CatalogChannelDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Height = d.Height, Flange = d.Flange, Web = d.Web,
StockItems = stockItems
});
break;
case MaterialShape.FlatBar when m.Dimensions is FlatBarDimensions d:
materialsDto.FlatBars.Add(new CatalogFlatBarDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Width = d.Width, Thickness = d.Thickness,
StockItems = stockItems
});
break;
case MaterialShape.IBeam when m.Dimensions is IBeamDimensions d:
materialsDto.IBeams.Add(new CatalogIBeamDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Height = d.Height, WeightPerFoot = d.WeightPerFoot,
StockItems = stockItems
});
break;
case MaterialShape.Pipe when m.Dimensions is PipeDimensions d:
materialsDto.Pipes.Add(new CatalogPipeDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
NominalSize = d.NominalSize, Wall = d.Wall ?? 0, Schedule = d.Schedule,
StockItems = stockItems
});
break;
case MaterialShape.RectangularTube when m.Dimensions is RectangularTubeDimensions d:
materialsDto.RectangularTubes.Add(new CatalogRectangularTubeDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Width = d.Width, Height = d.Height, Wall = d.Wall,
StockItems = stockItems
});
break;
case MaterialShape.RoundBar when m.Dimensions is RoundBarDimensions d:
materialsDto.RoundBars.Add(new CatalogRoundBarDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
Diameter = d.Diameter,
StockItems = stockItems
});
break;
case MaterialShape.RoundTube when m.Dimensions is RoundTubeDimensions d:
materialsDto.RoundTubes.Add(new CatalogRoundTubeDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
OuterDiameter = d.OuterDiameter, Wall = d.Wall,
StockItems = stockItems
});
break;
case MaterialShape.SquareBar when m.Dimensions is SquareBarDimensions d:
materialsDto.SquareBars.Add(new CatalogSquareBarDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
SideLength = d.Size,
StockItems = stockItems
});
break;
case MaterialShape.SquareTube when m.Dimensions is SquareTubeDimensions d:
materialsDto.SquareTubes.Add(new CatalogSquareTubeDto
{
Type = m.Type.ToString(), Grade = m.Grade, Size = m.Size, Description = m.Description,
SideLength = d.Size, Wall = d.Wall,
StockItems = stockItems
});
break;
}
}
}
return new CatalogData
{
ExportedAt = DateTime.UtcNow,
Suppliers = suppliers.Select(s => new CatalogSupplierDto
{
Name = s.Name,
ContactInfo = s.ContactInfo,
Notes = s.Notes
}).ToList(),
CuttingTools = cuttingTools.Select(t => new CatalogCuttingToolDto
{
Name = t.Name,
KerfInches = t.KerfInches,
IsDefault = t.IsDefault
}).ToList(),
Materials = materialsDto
};
}
public async Task<ImportResultDto> ImportAsync(CatalogData data)
{
var result = new ImportResultDto();
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 1. Suppliers - upsert by name
var supplierMap = await ImportSuppliersAsync(data.Suppliers, result);
// 2. Cutting tools - upsert by name
await ImportCuttingToolsAsync(data.CuttingTools, result);
// 3. Materials + stock items + offerings
await ImportAllMaterialsAsync(data.Materials, supplierMap, result);
await transaction.CommitAsync();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
result.Errors.Add($"Transaction failed: {ex.Message}");
}
return result;
}
private async Task<Dictionary<string, int>> ImportSuppliersAsync(
List<CatalogSupplierDto> suppliers, ImportResultDto result)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var existingSuppliers = await _context.Suppliers.ToListAsync();
foreach (var dto in suppliers)
{
try
{
var existing = existingSuppliers.FirstOrDefault(
s => s.Name.Equals(dto.Name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.ContactInfo = dto.ContactInfo ?? existing.ContactInfo;
existing.Notes = dto.Notes ?? existing.Notes;
existing.IsActive = true;
map[dto.Name] = existing.Id;
result.SuppliersUpdated++;
}
else
{
var supplier = new Supplier
{
Name = dto.Name,
ContactInfo = dto.ContactInfo,
Notes = dto.Notes,
CreatedAt = DateTime.UtcNow
};
_context.Suppliers.Add(supplier);
await _context.SaveChangesAsync();
existingSuppliers.Add(supplier);
map[dto.Name] = supplier.Id;
result.SuppliersCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Supplier '{dto.Name}': {ex.Message}");
}
}
await _context.SaveChangesAsync();
return map;
}
private async Task ImportCuttingToolsAsync(
List<CatalogCuttingToolDto> tools, ImportResultDto result)
{
var existingTools = await _context.CuttingTools.ToListAsync();
foreach (var dto in tools)
{
try
{
var existing = existingTools.FirstOrDefault(
t => t.Name.Equals(dto.Name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.KerfInches = dto.KerfInches;
existing.IsActive = true;
result.CuttingToolsUpdated++;
}
else
{
var tool = new CuttingTool
{
Name = dto.Name,
KerfInches = dto.KerfInches,
IsDefault = false
};
_context.CuttingTools.Add(tool);
existingTools.Add(tool);
result.CuttingToolsCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Cutting tool '{dto.Name}': {ex.Message}");
}
}
await _context.SaveChangesAsync();
}
private async Task ImportAllMaterialsAsync(
CatalogMaterialsDto materials, Dictionary<string, int> supplierMap, ImportResultDto result)
{
var existingMaterials = await _context.Materials
.Include(m => m.Dimensions)
.Include(m => m.StockItems)
.ThenInclude(s => s.SupplierOfferings)
.ToListAsync();
foreach (var dto in materials.Angles)
await ImportMaterialAsync(dto, MaterialShape.Angle, existingMaterials, supplierMap, result,
() => new AngleDimensions { Leg1 = dto.Leg1, Leg2 = dto.Leg2, Thickness = dto.Thickness },
dim => { var d = (AngleDimensions)dim; d.Leg1 = dto.Leg1; d.Leg2 = dto.Leg2; d.Thickness = dto.Thickness; });
foreach (var dto in materials.Channels)
await ImportMaterialAsync(dto, MaterialShape.Channel, existingMaterials, supplierMap, result,
() => new ChannelDimensions { Height = dto.Height, Flange = dto.Flange, Web = dto.Web },
dim => { var d = (ChannelDimensions)dim; d.Height = dto.Height; d.Flange = dto.Flange; d.Web = dto.Web; });
foreach (var dto in materials.FlatBars)
await ImportMaterialAsync(dto, MaterialShape.FlatBar, existingMaterials, supplierMap, result,
() => new FlatBarDimensions { Width = dto.Width, Thickness = dto.Thickness },
dim => { var d = (FlatBarDimensions)dim; d.Width = dto.Width; d.Thickness = dto.Thickness; });
foreach (var dto in materials.IBeams)
await ImportMaterialAsync(dto, MaterialShape.IBeam, existingMaterials, supplierMap, result,
() => new IBeamDimensions { Height = dto.Height, WeightPerFoot = dto.WeightPerFoot },
dim => { var d = (IBeamDimensions)dim; d.Height = dto.Height; d.WeightPerFoot = dto.WeightPerFoot; });
foreach (var dto in materials.Pipes)
await ImportMaterialAsync(dto, MaterialShape.Pipe, existingMaterials, supplierMap, result,
() => new PipeDimensions { NominalSize = dto.NominalSize, Wall = dto.Wall, Schedule = dto.Schedule },
dim => { var d = (PipeDimensions)dim; d.NominalSize = dto.NominalSize; d.Wall = (decimal?)dto.Wall; d.Schedule = dto.Schedule; });
foreach (var dto in materials.RectangularTubes)
await ImportMaterialAsync(dto, MaterialShape.RectangularTube, existingMaterials, supplierMap, result,
() => new RectangularTubeDimensions { Width = dto.Width, Height = dto.Height, Wall = dto.Wall },
dim => { var d = (RectangularTubeDimensions)dim; d.Width = dto.Width; d.Height = dto.Height; d.Wall = dto.Wall; });
foreach (var dto in materials.RoundBars)
await ImportMaterialAsync(dto, MaterialShape.RoundBar, existingMaterials, supplierMap, result,
() => new RoundBarDimensions { Diameter = dto.Diameter },
dim => { var d = (RoundBarDimensions)dim; d.Diameter = dto.Diameter; });
foreach (var dto in materials.RoundTubes)
await ImportMaterialAsync(dto, MaterialShape.RoundTube, existingMaterials, supplierMap, result,
() => new RoundTubeDimensions { OuterDiameter = dto.OuterDiameter, Wall = dto.Wall },
dim => { var d = (RoundTubeDimensions)dim; d.OuterDiameter = dto.OuterDiameter; d.Wall = dto.Wall; });
foreach (var dto in materials.SquareBars)
await ImportMaterialAsync(dto, MaterialShape.SquareBar, existingMaterials, supplierMap, result,
() => new SquareBarDimensions { Size = dto.SideLength },
dim => { var d = (SquareBarDimensions)dim; d.Size = dto.SideLength; });
foreach (var dto in materials.SquareTubes)
await ImportMaterialAsync(dto, MaterialShape.SquareTube, existingMaterials, supplierMap, result,
() => new SquareTubeDimensions { Size = dto.SideLength, Wall = dto.Wall },
dim => { var d = (SquareTubeDimensions)dim; d.Size = dto.SideLength; d.Wall = dto.Wall; });
}
private async Task ImportMaterialAsync(
CatalogMaterialBaseDto dto, MaterialShape shape,
List<Material> existingMaterials, Dictionary<string, int> supplierMap,
ImportResultDto result,
Func<MaterialDimensions> createDimensions,
Action<MaterialDimensions> updateDimensions)
{
try
{
if (!Enum.TryParse<MaterialType>(dto.Type, ignoreCase: true, out var type))
{
type = MaterialType.Steel;
result.Warnings.Add($"Material '{shape} - {dto.Size}': Unknown type '{dto.Type}', defaulting to Steel");
}
var existing = existingMaterials.FirstOrDefault(
m => m.Shape == shape && m.Size.Equals(dto.Size, StringComparison.OrdinalIgnoreCase));
Material material;
if (existing != null)
{
existing.Type = type;
existing.Grade = dto.Grade ?? existing.Grade;
existing.Description = dto.Description ?? existing.Description;
existing.IsActive = true;
existing.UpdatedAt = DateTime.UtcNow;
if (existing.Dimensions != null)
{
updateDimensions(existing.Dimensions);
existing.SortOrder = existing.Dimensions.GetSortOrder();
}
material = existing;
result.MaterialsUpdated++;
}
else
{
material = new Material
{
Shape = shape,
Type = type,
Grade = dto.Grade,
Size = dto.Size,
Description = dto.Description,
CreatedAt = DateTime.UtcNow
};
var dimensions = createDimensions();
material = await _materialService.CreateWithDimensionsAsync(material, dimensions);
existingMaterials.Add(material);
result.MaterialsCreated++;
}
await _context.SaveChangesAsync();
await ImportStockItemsAsync(material, dto.StockItems, supplierMap, result);
}
catch (Exception ex)
{
result.Errors.Add($"Material '{shape} - {dto.Size}': {ex.Message}");
}
}
private async Task ImportStockItemsAsync(
Material material, List<CatalogStockItemDto> stockItems,
Dictionary<string, int> supplierMap, ImportResultDto result)
{
var existingStockItems = await _context.StockItems
.Include(s => s.SupplierOfferings)
.Where(s => s.MaterialId == material.Id)
.ToListAsync();
foreach (var dto in stockItems)
{
try
{
var existing = existingStockItems.FirstOrDefault(
s => s.LengthInches == dto.LengthInches);
StockItem stockItem;
if (existing != null)
{
existing.Name = dto.Name ?? existing.Name;
existing.Notes = dto.Notes ?? existing.Notes;
existing.IsActive = true;
existing.UpdatedAt = DateTime.UtcNow;
stockItem = existing;
result.StockItemsUpdated++;
}
else
{
stockItem = new StockItem
{
MaterialId = material.Id,
LengthInches = dto.LengthInches,
Name = dto.Name,
QuantityOnHand = dto.QuantityOnHand,
Notes = dto.Notes,
CreatedAt = DateTime.UtcNow
};
_context.StockItems.Add(stockItem);
await _context.SaveChangesAsync();
existingStockItems.Add(stockItem);
result.StockItemsCreated++;
}
foreach (var offeringDto in dto.SupplierOfferings)
{
try
{
if (!supplierMap.TryGetValue(offeringDto.SupplierName, out var supplierId))
{
result.Warnings.Add(
$"Offering for stock '{material.DisplayName} @ {dto.LengthInches}\"': " +
$"Unknown supplier '{offeringDto.SupplierName}', skipped");
continue;
}
var existingOffering = stockItem.SupplierOfferings.FirstOrDefault(
o => o.SupplierId == supplierId);
if (existingOffering != null)
{
existingOffering.PartNumber = offeringDto.PartNumber ?? existingOffering.PartNumber;
existingOffering.SupplierDescription = offeringDto.SupplierDescription ?? existingOffering.SupplierDescription;
existingOffering.Price = offeringDto.Price ?? existingOffering.Price;
existingOffering.Notes = offeringDto.Notes ?? existingOffering.Notes;
existingOffering.IsActive = true;
result.OfferingsUpdated++;
}
else
{
var offering = new SupplierOffering
{
StockItemId = stockItem.Id,
SupplierId = supplierId,
PartNumber = offeringDto.PartNumber,
SupplierDescription = offeringDto.SupplierDescription,
Price = offeringDto.Price,
Notes = offeringDto.Notes
};
_context.SupplierOfferings.Add(offering);
stockItem.SupplierOfferings.Add(offering);
result.OfferingsCreated++;
}
}
catch (Exception ex)
{
result.Errors.Add(
$"Offering for '{material.DisplayName} @ {dto.LengthInches}\"' " +
$"from '{offeringDto.SupplierName}': {ex.Message}");
}
}
await _context.SaveChangesAsync();
}
catch (Exception ex)
{
result.Errors.Add(
$"Stock item '{material.DisplayName} @ {dto.LengthInches}\"': {ex.Message}");
}
}
}
private static List<CatalogStockItemDto> MapStockItems(Material m, List<Supplier> suppliers)
{
return m.StockItems.OrderBy(s => s.LengthInches).Select(s => new CatalogStockItemDto
{
LengthInches = s.LengthInches,
Name = s.Name,
QuantityOnHand = s.QuantityOnHand,
Notes = s.Notes,
SupplierOfferings = s.SupplierOfferings.Select(o => new CatalogSupplierOfferingDto
{
SupplierName = suppliers.FirstOrDefault(sup => sup.Id == o.SupplierId)?.Name ?? "Unknown",
PartNumber = o.PartNumber,
SupplierDescription = o.SupplierDescription,
Price = o.Price,
Notes = o.Notes
}).ToList()
}).ToList();
}
}
@@ -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;
}
}
+58 -1
View File
@@ -72,10 +72,32 @@ 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();
}
public async Task LockAsync(int id)
{
var job = await _context.Jobs.FindAsync(id);
if (job != null)
{
job.LockedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task UnlockAsync(int id)
{
var job = await _context.Jobs.FindAsync(id);
if (job != null)
{
job.LockedAt = null;
await _context.SaveChangesAsync();
}
}
public async Task DeleteAsync(int id)
{
var job = await _context.Jobs.FindAsync(id);
@@ -141,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)
{
@@ -152,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();
}
@@ -172,6 +219,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -189,6 +238,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -209,6 +260,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
@@ -224,6 +277,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
@@ -241,6 +296,8 @@ public class JobService
if (job != null)
{
job.UpdatedAt = DateTime.UtcNow;
job.OptimizationResultJson = null;
job.OptimizedAt = null;
await _context.SaveChangesAsync();
}
}
+103
View File
@@ -0,0 +1,103 @@
using CutList.Web.Data;
using CutList.Web.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CutList.Web.Services;
public class PurchaseItemService
{
private readonly ApplicationDbContext _context;
public PurchaseItemService(ApplicationDbContext context)
{
_context = context;
}
public async Task<List<PurchaseItem>> GetAllAsync(PurchaseItemStatus? status = null)
{
var query = _context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.AsQueryable();
if (status.HasValue)
{
query = query.Where(p => p.Status == status.Value);
}
return await query
.OrderBy(p => p.Status)
.ThenByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<PurchaseItem?> GetByIdAsync(int id)
{
return await _context.PurchaseItems
.Include(p => p.StockItem)
.ThenInclude(s => s.Material)
.Include(p => p.Supplier)
.Include(p => p.Job)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<PurchaseItem> CreateAsync(PurchaseItem item)
{
item.CreatedAt = DateTime.UtcNow;
_context.PurchaseItems.Add(item);
await _context.SaveChangesAsync();
return item;
}
public async Task CreateBulkAsync(List<PurchaseItem> items)
{
var now = DateTime.UtcNow;
foreach (var item in items)
{
item.CreatedAt = now;
}
_context.PurchaseItems.AddRange(items);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(PurchaseItem item)
{
item.UpdatedAt = DateTime.UtcNow;
_context.PurchaseItems.Update(item);
await _context.SaveChangesAsync();
}
public async Task UpdateStatusAsync(int id, PurchaseItemStatus status)
{
var item = await _context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.Status = status;
item.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task UpdateSupplierAsync(int id, int? supplierId)
{
var item = await _context.PurchaseItems.FindAsync(id);
if (item != null)
{
item.SupplierId = supplierId;
item.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
public async Task DeleteAsync(int id)
{
var item = await _context.PurchaseItems.FindAsync(id);
if (item != null)
{
_context.PurchaseItems.Remove(item);
await _context.SaveChangesAsync();
}
}
}
+1 -1
View File
@@ -8,6 +8,6 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"
"DefaultConnection": "Server=localhost\\SQLEXPRESS;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
}
}
+106 -6
View File
@@ -136,6 +136,11 @@
white-space: pre-wrap;
}
/* Cut list material headers — hidden on screen, shown in print via repeating thead */
.cutlist-material-print-header {
display: none;
}
/* Print styles - Compact layout to save paper */
@media print {
body {
@@ -258,19 +263,114 @@
font-size: 8pt;
}
.alert {
display: none !important;
/* Compact summary: inline row instead of big cards */
.print-summary {
display: flex !important;
flex-wrap: nowrap !important;
gap: 0 !important;
margin-bottom: 0.5rem !important;
border: 1px solid #ccc;
padding: 0.4rem 0.75rem !important;
}
.print-summary > div {
flex: 1 !important;
max-width: none !important;
width: auto !important;
margin: 0 !important;
padding: 0 !important;
}
.print-summary .card {
border: none !important;
background: none !important;
box-shadow: none !important;
}
.print-summary .card-body {
padding: 0 !important;
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.print-summary h2 {
font-size: 11pt !important;
margin: 0 !important;
}
.print-summary p {
font-size: 8pt !important;
margin: 0 !important;
}
/* Keep purchase list with cut lists to save paper */
.print-purchase-list {
break-inside: avoid;
page-break-inside: avoid;
}
/* General card print styles — allow large cards to break across pages */
.card {
border: 1px solid #ccc !important;
}
/* Keep card headers with the start of their content */
.card-header {
break-after: avoid;
page-break-after: avoid;
}
.card-header {
background-color: #f0f0f0 !important;
}
.badge {
border: 1px solid #999;
}
/* Cut list tables: hide screen header, show repeating print header in thead */
.cutlist-material-screen-header {
display: none !important;
}
h1:not(.report-header h1) {
display: none !important;
.cutlist-material-print-header {
display: table-row !important;
}
.text-muted:not(.cut-list-report .text-muted) {
display: none !important;
.cutlist-material-print-header th {
background: #f0f0f0 !important;
padding: 0.4rem 0.5rem !important;
border-bottom: 1px solid #ccc !important;
}
.cutlist-material-name {
font-size: 12pt;
font-weight: 700;
}
.cutlist-material-stats {
float: right;
font-size: 9pt;
font-weight: 400;
color: #666;
}
/* Remove card border/padding for cut list cards in print — table handles it */
.cutlist-material-card {
border: none !important;
}
.cutlist-material-card > .card-body {
padding: 0 !important;
}
/* Reduce spacing */
.mb-4 {
margin-bottom: 0.5rem !important;
}
.mb-3 {
margin-bottom: 0.25rem !important;
}
}
+83
View File
@@ -0,0 +1,83 @@
# Alro Steel SmartGrid Scraper — Remaining Steps
## Status: Script is READY TO RUN
The scraper at `scripts/AlroCatalog/scrape_alro.py` is complete and tested. Discovery mode confirmed it works correctly against the live site.
## What's Done
1. Script written with correct ASP.NET control IDs (discovered via `--discover` mode)
2. Level 1 (main grid) navigation: working
3. Level 2 (popup grid) navigation: working
4. Level 3 (dims panel) scraping: working — uses cascading dropdowns `ddlDimA``ddlDimB``ddlDimC``ddlLength`
5. Grade filter: 11 common grades (A-36, 1018, 1045, 1144, 12L14, etc.)
6. Size string normalization: "1-1/2\"" matches O'Neal format
7. Progress save/resume: working
8. Discovery mode verified: A-36 Round bars → 27 sizes, 80 items (lengths include "20 FT", "Custom Cut List", "Drop/Remnant" — non-stock entries filtered out in catalog builder)
## Remaining Steps
### Step 1: Run the full scrape
```bash
cd C:\Users\aisaacs\Desktop\Projects\CutList
python scripts/AlroCatalog/scrape_alro.py
```
- This scrapes all 3 categories (Bars, Pipe/Tube, Structural) for 11 filtered grades
- Takes ~30-60 minutes (cascading dropdown selections with 1.5s delay each)
- Progress saved incrementally to `scripts/AlroCatalog/alro-scrape-progress.json`
- If interrupted, resume with `python scripts/AlroCatalog/scrape_alro.py --resume`
- To scrape ALL grades: `python scripts/AlroCatalog/scrape_alro.py --all-grades`
### Step 2: Review output
- Output: `CutList.Web/Data/SeedData/alro-catalog.json`
- Verify material counts, shapes, sizes
- Spot-check dimensions against myalro.com
- Compare shape coverage to O'Neal catalog
### Step 3: Post-scrape adjustments (if needed)
**Dimension mapping for Structural/Pipe shapes**: The `build_size_and_dims()` function handles all shapes but Structural (Angle, Channel, Beam) and Pipe/Tube shapes haven't been tested live yet. After scraping, check the screenshots in `scripts/AlroCatalog/screenshots/` to verify dimension mapping. The first item of each new shape gets a screenshot + HTML dump.
**Known dimension mapping assumptions:**
- Angle: DimA = leg size, DimB = thickness → `"leg1 x leg2 x thickness"` (assumes equal legs)
- Channel: DimA = height, DimB = flange → needs verification
- IBeam: DimA = depth, DimB = weight/ft → `"W{depth} x {wt}"`
- SquareTube: DimA = size, DimB = wall
- RectTube: DimA = width, DimB = height, DimC = wall
- RoundTube: DimA = OD, DimB = wall
- Pipe: DimA = NPS, DimB = schedule
**If dimension mapping is wrong for a shape**: Edit the `build_size_and_dims()` function in `scrape_alro.py` and re-run just the catalog builder:
```python
python -c "
import json
from scripts.AlroCatalog.scrape_alro import build_catalog
data = json.load(open('scripts/AlroCatalog/alro-scrape-progress.json'))
catalog = build_catalog(data['items'])
json.dump(catalog, open('CutList.Web/Data/SeedData/alro-catalog.json', 'w'), indent=2)
"
```
### Step 4: Part numbers (optional future enhancement)
The current scraper captures sizes and lengths but NOT part numbers. To get part numbers, the script would need to:
1. Select DimA + DimB + Length
2. Click the "Next >" button (`btnSearch`)
3. Capture part number from the results panel
4. Click Back
This adds significant time per item. The catalog works without part numbers — the supplierOfferings have empty partNumber/supplierDescription fields.
## Key Files
| File | Purpose |
|------|---------|
| `scripts/AlroCatalog/scrape_alro.py` | The scraper script |
| `scripts/AlroCatalog/alro-scrape-progress.json` | Incremental progress (resume support) |
| `scripts/AlroCatalog/screenshots/` | Discovery HTML/screenshots per shape |
| `CutList.Web/Data/SeedData/alro-catalog.json` | Final output (same schema as oneals-catalog.json) |
| `CutList.Web/Data/SeedData/oneals-catalog.json` | Reference format |
## Grade Filter (editable in script)
Located at line ~50 in `scrape_alro.py`. Current filter:
- A-36, 1018 CF, 1018 HR, 1044 HR, 1045 CF, 1045 HR, 1045 TG&P
- 1144 CF, 1144 HR, 12L14 CF, A311/Stressproof
To add/remove grades, edit the `GRADE_FILTER` set in the script.
@@ -0,0 +1,976 @@
{
"completed": [
[
"Bars",
"A-36",
"ROUND"
],
[
"Bars",
"A-36",
"FLAT"
]
],
"items": [
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".188",
"dim_a_text": "3/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".188",
"dim_a_text": "3/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".250",
"dim_a_text": "1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".313",
"dim_a_text": "5/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".375",
"dim_a_text": "3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".438",
"dim_a_text": "7/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".500",
"dim_a_text": "1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".563",
"dim_a_text": "9/16",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".625",
"dim_a_text": "5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".750",
"dim_a_text": "3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": ".875",
"dim_a_text": "7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.000",
"dim_a_text": "1",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.125",
"dim_a_text": "1 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.250",
"dim_a_text": "1 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.375",
"dim_a_text": "1 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.500",
"dim_a_text": "1 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.625",
"dim_a_text": "1 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.750",
"dim_a_text": "1 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "1.875",
"dim_a_text": "1 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.000",
"dim_a_text": "2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.125",
"dim_a_text": "2 1/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.250",
"dim_a_text": "2 1/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.375",
"dim_a_text": "2 3/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.500",
"dim_a_text": "2 1/2",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.625",
"dim_a_text": "2 5/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.750",
"dim_a_text": "2 3/4",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "2.875",
"dim_a_text": "2 7/8",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Custom Cut List",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "Drop/Remnant",
"length_inches": null
},
{
"grade": "A-36",
"shape": "RoundBar",
"dim_a_val": "3.000",
"dim_a_text": "3",
"dim_b_val": null,
"dim_b_text": null,
"dim_c_val": null,
"dim_c_text": null,
"length_text": "20 FT",
"length_inches": 240.0
}
]
}
+790
View File
@@ -0,0 +1,790 @@
#!/usr/bin/env python3
"""
Alro Steel SmartGrid Scraper
Scrapes myalro.com's SmartGrid for Carbon Steel materials and outputs
a catalog JSON matching the O'Neal catalog format.
Usage:
python scrape_alro.py # Scrape filtered grades (resumes from saved progress)
python scrape_alro.py --all-grades # Scrape ALL grades (slow)
python scrape_alro.py --discover # Scrape first item only, dump HTML/screenshots
python scrape_alro.py --fresh # Start fresh, ignoring saved progress
"""
import asyncio
import json
import re
import sys
import logging
from datetime import datetime, timezone
from pathlib import Path
from playwright.async_api import async_playwright, Page, TimeoutError as PwTimeout
from playwright_stealth import Stealth
# ── Logging ──────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)
# ── Paths ────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).parent.resolve()
OUTPUT_PATH = (SCRIPT_DIR / "../../CutList.Web/Data/SeedData/alro-catalog.json").resolve()
PROGRESS_PATH = SCRIPT_DIR / "alro-scrape-progress.json"
SCREENSHOTS_DIR = SCRIPT_DIR / "screenshots"
# ── Config ───────────────────────────────────────────────────────────
BASE_URL = "https://www.myalro.com/SmartGrid.aspx?PT=Steel&Clear=true"
DELAY = 5 # seconds between postback clicks
TIMEOUT = 15_000 # ms for element waits
CS_ROW = 4 # Carbon Steel row index in main grid
CATEGORIES = ["Bars", "Pipe / Tube", "Structural"]
# ┌─────────────────────────────────────────────────────────────────┐
# │ GRADE FILTER — only these grades will be scraped. │
# │ Use --all-grades flag to override and scrape everything. │
# │ Grade names must match the gpname attribute exactly. │
# └─────────────────────────────────────────────────────────────────┘
GRADE_FILTER = {
# Common structural / general purpose
"A-36",
# Mild steel
"1018 CF",
"1018 HR",
# Medium carbon (shafts, gears, pins)
"1045 CF",
"1045 HR",
"1045 TG&P",
# Free-machining
"1144 CF",
"1144 HR",
"12L14 CF",
# Hot-rolled plate/bar
"1044 HR",
# Stressproof (high-strength shafting)
"A311/Stressproof",
}
# Alro shape column header → our MaterialShape enum
SHAPE_MAP = {
"ROUND": "RoundBar",
"FLAT": "FlatBar",
"SQUARE": "SquareBar",
"ANGLE": "Angle",
"CHANNEL": "Channel",
"BEAM": "IBeam",
"SQ TUBE": "SquareTube",
"SQUARE TUBE": "SquareTube",
"REC TUBE": "RectangularTube",
"RECT TUBE": "RectangularTube",
"RECTANGULAR TUBE": "RectangularTube",
"ROUND TUBE": "RoundTube",
"RND TUBE": "RoundTube",
"PIPE": "Pipe",
}
# ── ASP.NET control IDs ─────────────────────────────────────────
_CP = "ctl00_ContentPlaceHolder1"
_PU = f"{_CP}_pnlPopUP"
ID = dict(
main_grid = f"{_CP}_grdMain",
popup_grid = f"{_PU}_grdPopUp",
popup_window = f"{_PU}_Window",
dims_panel = f"{_PU}_upnlDims",
back_btn = f"{_PU}_btnBack",
# Dimension dropdowns (cascading: A → B → C → Length)
dim_a = f"{_PU}_ddlDimA",
dim_b = f"{_PU}_ddlDimB",
dim_c = f"{_PU}_ddlDimC",
dim_length = f"{_PU}_ddlLength",
btn_next = f"{_PU}_btnSearch",
)
# Postback targets ($ separators)
PB = dict(
main_grid = "ctl00$ContentPlaceHolder1$grdMain",
popup_grid = "ctl00$ContentPlaceHolder1$pnlPopUP$grdPopUp",
back_btn = "ctl00$ContentPlaceHolder1$pnlPopUP$btnBack",
popup = "ctl00$ContentPlaceHolder1$pnlPopUP",
dim_a = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimA",
dim_b = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimB",
dim_c = "ctl00$ContentPlaceHolder1$pnlPopUP$ddlDimC",
)
# ═══════════════════════════════════════════════════════════════════════
# Utility helpers
# ═══════════════════════════════════════════════════════════════════════
def parse_fraction(s: str) -> float | None:
"""Parse fraction/decimal string → float. '1-1/4' → 1.25, '.250' → 0.25"""
if not s:
return None
s = s.strip().strip('"\'')
# Collapse double spaces from Alro dropdown text ("1 1/4" → "1 1/4")
s = re.sub(r"\s+", " ", s)
if not s:
return None
try:
return float(s)
except ValueError:
pass
# Mixed fraction: "1-1/4" or "1 1/4"
m = re.match(r"^(\d+)[\s-](\d+)/(\d+)$", s)
if m:
return int(m[1]) + int(m[2]) / int(m[3])
m = re.match(r"^(\d+)/(\d+)$", s)
if m:
return int(m[1]) / int(m[2])
m = re.match(r"^(\d+)$", s)
if m:
return float(m[1])
return None
def decimal_to_fraction(value: float) -> str:
"""0.25 → '1/4', 1.25 → '1-1/4', 3.0 → '3'"""
if value <= 0:
return "0"
whole = int(value)
frac = value - whole
if abs(frac) < 0.001:
return str(whole)
from math import gcd
sixteenths = round(frac * 16)
if sixteenths == 16:
return str(whole + 1)
g = gcd(sixteenths, 16)
num, den = sixteenths // g, 16 // g
frac_s = f"{num}/{den}"
return f"{whole}-{frac_s}" if whole else frac_s
def normalize_dim_text(s: str) -> str:
"""Normalize dimension text: '1 1/4''1-1/4', '3/16''3/16'"""
s = re.sub(r"\s+", " ", s.strip())
# "1 1/4" → "1-1/4" (mixed fraction with space → hyphen)
s = re.sub(r"^(\d+)\s+(\d+/\d+)$", r"\1-\2", s)
return s
def parse_length_to_inches(text: str) -> float | None:
"""Parse length string to inches. \"20'\" → 240, \"240\" → 240"""
s = text.strip().upper()
s = re.sub(r"\s*(RL|RANDOM.*|LENGTHS?|EA|EACH|STOCK)\s*", "", s).strip()
m = re.match(r"^(\d+(?:\.\d+)?)\s*['\u2032]", s)
if m:
return float(m[1]) * 12
m = re.match(r"^(\d+(?:\.\d+)?)\s*FT", s)
if m:
return float(m[1]) * 12
m = re.match(r'^(\d+(?:\.\d+)?)\s*"?\s*$', s)
if m:
v = float(m[1])
return v * 12 if v <= 30 else v
return None
# ═══════════════════════════════════════════════════════════════════════
# SmartGrid navigation
# ═══════════════════════════════════════════════════════════════════════
async def wait_for_update(page: Page, timeout: int = TIMEOUT):
"""Wait for ASP.NET partial postback to finish."""
try:
await page.wait_for_load_state("networkidle", timeout=timeout)
except PwTimeout:
log.warning(" networkidle timeout continuing")
await asyncio.sleep(0.5)
async def do_postback(page: Page, target: str, arg: str):
"""Execute a __doPostBack call."""
await page.evaluate(f"__doPostBack('{target}', '{arg}')")
async def click_category(page: Page, category: str) -> bool:
"""Click a category blue-button for Carbon Steel in the main grid."""
log.info(f"Clicking main grid: {category} (row {CS_ROW})")
arg = f"{category}${CS_ROW}"
link = await page.query_selector(
f"#{ID['main_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
)
if not link:
log.error(f" Button not found for {arg}")
return False
parent = await link.evaluate_handle("el => el.parentElement")
await parent.as_element().click()
try:
await page.wait_for_selector(f"#{ID['popup_grid']}", state="visible", timeout=TIMEOUT)
await wait_for_update(page)
return True
except PwTimeout:
log.error(f" Popup did not appear for {category}")
return False
async def scrape_popup_grid(page: Page):
"""Parse the popup grid → [(grade_name, grade_id, shape, row_idx, has_btn)]."""
headers = await page.eval_on_selector_all(
f"#{ID['popup_grid']} tr.DataHeader th",
"els => els.map(el => el.textContent.trim())",
)
log.info(f" Popup columns: {headers}")
rows = await page.query_selector_all(
f"#{ID['popup_grid']} tr.griditemP, #{ID['popup_grid']} tr.gridaltItemP"
)
combos = []
for row_idx, row in enumerate(rows):
first_td = await row.query_selector("td[gpid]")
if not first_td:
continue
gid = (await first_td.get_attribute("gpid") or "").strip()
gname = (await first_td.get_attribute("gpname") or "").strip()
tds = await row.query_selector_all("td")
for col_idx, td in enumerate(tds):
if col_idx == 0:
continue
shape = headers[col_idx] if col_idx < len(headers) else ""
img = await td.query_selector("img[src*='blue_button']")
combos.append((gname, gid, shape, row_idx, img is not None))
active = sum(1 for c in combos if c[4])
log.info(f" {active} active grade/shape combos")
return combos
async def click_shape(page: Page, shape: str, row_idx: int) -> bool:
"""Click a shape button in the popup grid; wait for dims panel."""
arg = f"{shape}${row_idx}"
link = await page.query_selector(
f"#{ID['popup_grid']} a[href*=\"'{arg}'\"] img[src*='blue_button']"
)
if not link:
try:
await do_postback(page, PB["popup_grid"], arg)
except Exception:
log.warning(f" Could not click shape {arg}")
return False
else:
parent = await link.evaluate_handle("el => el.parentElement")
await parent.as_element().click()
try:
# Wait for the DimA dropdown to appear (the real indicator of dims panel loaded)
await page.wait_for_selector(f"#{ID['dim_a']}", state="attached", timeout=TIMEOUT)
await wait_for_update(page)
return True
except PwTimeout:
# Check if panel has any content at all
html = await page.inner_html(f"#{ID['dims_panel']}")
if len(html.strip()) > 50:
await wait_for_update(page)
return True
log.warning(f" Dims panel timeout for {arg}")
return False
async def click_back(page: Page):
"""Click Back to return to the popup grid view."""
try:
await do_postback(page, PB["back_btn"], "")
await wait_for_update(page)
await asyncio.sleep(DELAY)
except Exception as e:
log.warning(f" Back button error: {e}")
async def close_popup(page: Page):
"""Close the popup window and return to the main grid."""
try:
await do_postback(page, PB["popup"], "Close")
await wait_for_update(page)
await asyncio.sleep(DELAY)
except Exception as e:
log.warning(f" Close popup error: {e}")
# ═══════════════════════════════════════════════════════════════════════
# Level 3 — Dimension Panel Scraping
# ═══════════════════════════════════════════════════════════════════════
async def get_select_options(page: Page, sel_id: str):
"""Return [(value, text), ...] for a <select>, excluding placeholders."""
el = await page.query_selector(f"#{sel_id}")
if not el:
return []
# Check if disabled
disabled = await el.get_attribute("disabled")
if disabled:
return []
try:
opts = await page.eval_on_selector(
f"#{sel_id}",
"""el => Array.from(el.options).map(o => ({
v: o.value, t: o.text.trim(), d: o.disabled
}))""",
)
except Exception:
return []
return [
(o["v"], o["t"])
for o in opts
if o["v"] and o["v"] != "-1" and o["t"] and not o["d"]
and o["t"].lower() not in ("- select -", "--select--", "select...", "select", "")
]
async def scrape_dims_panel(page: Page, grade: str, shape_alro: str,
shape_mapped: str, *, save_discovery: bool = False,
on_item=None, scraped_dim_a: set[str] | None = None):
"""Main Level 3 extraction. Returns list of raw item dicts.
If on_item callback is provided, it is called with each item dict
as soon as it is discovered (for incremental saving).
If scraped_dim_a is provided, DimA values in that set are skipped (resume).
"""
items: list[dict] = []
if save_discovery:
SCREENSHOTS_DIR.mkdir(exist_ok=True)
safe = f"{grade}_{shape_alro}".replace(" ", "_").replace("/", "-")
await page.screenshot(path=str(SCREENSHOTS_DIR / f"dims_{safe}.png"), full_page=True)
html = await page.inner_html(f"#{ID['dims_panel']}")
(SCREENSHOTS_DIR / f"dims_{safe}.html").write_text(html, encoding="utf-8")
log.info(f" Discovery saved → screenshots/dims_{safe}.*")
# ── Get DimA options (primary dimension: diameter, width, size, etc.) ──
dim_a_opts = await get_select_options(page, ID["dim_a"])
if not dim_a_opts:
log.warning(f" No DimA options found")
try:
html = await page.inner_html(f"#{ID['dims_panel']}")
if len(html) > 50:
SCREENSHOTS_DIR.mkdir(exist_ok=True)
safe = f"{grade}_{shape_alro}_nodimopts".replace(" ", "_").replace("/", "-")
(SCREENSHOTS_DIR / f"{safe}.html").write_text(html, encoding="utf-8")
except Exception as e:
log.warning(f" Could not dump dims panel: {e}")
return []
already_done = scraped_dim_a or set()
remaining = [(v, t) for v, t in dim_a_opts if v not in already_done]
if already_done:
log.info(f" DimA: {len(dim_a_opts)} sizes ({len(dim_a_opts) - len(remaining)} already scraped, {len(remaining)} remaining)")
else:
log.info(f" DimA: {len(dim_a_opts)} sizes")
# All DimA values already scraped — combo is complete
if not remaining:
return []
for a_val, a_text in remaining:
# Select DimA → triggers postback → DimB/Length populate
await page.select_option(f"#{ID['dim_a']}", a_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
# Check if DimB appeared (secondary dimension: thickness, wall, etc.)
dim_b_opts = await get_select_options(page, ID["dim_b"])
if dim_b_opts:
for b_val, b_text in dim_b_opts:
await page.select_option(f"#{ID['dim_b']}", b_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
# Check for DimC (tertiary — rare)
dim_c_opts = await get_select_options(page, ID["dim_c"])
if dim_c_opts:
for c_val, c_text in dim_c_opts:
await page.select_option(f"#{ID['dim_c']}", c_val)
await asyncio.sleep(DELAY)
await wait_for_update(page)
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, b_val, b_text, c_val, c_text,
l_text,
)
items.append(item)
if on_item:
on_item(item)
else:
# No DimC — read lengths
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, b_val, b_text, None, None,
l_text,
)
items.append(item)
if on_item:
on_item(item)
else:
# No DimB — just DimA + Length
lengths = await get_select_options(page, ID["dim_length"])
for l_val, l_text in lengths:
item = _make_item(
grade, shape_mapped,
a_val, a_text, None, None, None, None,
l_text,
)
items.append(item)
if on_item:
on_item(item)
return items
def _make_item(grade, shape, a_val, a_text, b_val, b_text, c_val, c_text, l_text):
"""Build a raw item dict from dimension selections."""
return {
"grade": grade,
"shape": shape,
"dim_a_val": a_val, # decimal string like ".500"
"dim_a_text": a_text, # fraction string like "1/2"
"dim_b_val": b_val,
"dim_b_text": b_text,
"dim_c_val": c_val,
"dim_c_text": c_text,
"length_text": l_text,
"length_inches": parse_length_to_inches(l_text),
}
# ═══════════════════════════════════════════════════════════════════════
# Output — build catalog JSON
# ═══════════════════════════════════════════════════════════════════════
def build_size_and_dims(shape: str, item: dict):
"""Return (size_string, dimensions_dict) for a catalog material entry.
Uses the decimal values from dropdown option values for precision,
and fraction text from dropdown option text for display.
"""
# Use the numeric value from the dropdown (e.g. ".500") for precision
a = float(item["dim_a_val"]) if item.get("dim_a_val") else None
b = float(item["dim_b_val"]) if item.get("dim_b_val") else None
c = float(item["dim_c_val"]) if item.get("dim_c_val") else None
a_txt = normalize_dim_text(item.get("dim_a_text") or "")
b_txt = normalize_dim_text(item.get("dim_b_text") or "")
c_txt = normalize_dim_text(item.get("dim_c_text") or "")
if shape == "RoundBar" and a is not None:
return f'{a_txt}"', {"diameter": round(a, 4)}
if shape == "FlatBar":
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"width": round(a, 4), "thickness": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"width": round(a, 4), "thickness": 0}
if shape == "SquareBar" and a is not None:
return f'{a_txt}"', {"sideLength": round(a, 4)}
if shape == "Angle":
if a is not None and b is not None:
return (f'{a_txt}" x {a_txt}" x {b_txt}"',
{"leg1": round(a, 4), "leg2": round(a, 4), "thickness": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"leg1": round(a, 4), "leg2": round(a, 4), "thickness": 0}
if shape == "Channel":
# Channels may use DimA for combined designation or height
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"height": round(a, 4), "flange": round(b, 4), "web": 0})
if a is not None:
return a_txt, {"height": round(a, 4), "flange": 0, "web": 0}
if shape == "IBeam":
# DimA might be the W-designation, DimB the weight/ft
if a is not None and b is not None:
return (f"W{int(a)} x {b}",
{"height": round(a, 4), "weightPerFoot": round(b, 4)})
if a is not None:
return f"W{int(a)}", {"height": round(a, 4), "weightPerFoot": 0}
if shape == "SquareTube":
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}" wall',
{"sideLength": round(a, 4), "wall": round(b, 4)})
if a is not None:
return f'{a_txt}"', {"sideLength": round(a, 4), "wall": 0}
if shape == "RectangularTube":
if a is not None and b is not None and c is not None:
return (f'{a_txt}" x {b_txt}" x {c_txt}" wall',
{"width": round(a, 4), "height": round(b, 4), "wall": round(c, 4)})
if a is not None and b is not None:
return (f'{a_txt}" x {b_txt}"',
{"width": round(a, 4), "height": round(b, 4), "wall": 0})
if shape == "RoundTube":
if a is not None and b is not None:
return (f'{a_txt}" OD x {b_txt}" wall',
{"outerDiameter": round(a, 4), "wall": round(b, 4)})
if a is not None:
return f'{a_txt}" OD', {"outerDiameter": round(a, 4), "wall": 0}
if shape == "Pipe":
sched = b_txt or c_txt or "40"
if a is not None:
return (f'{a_txt}" NPS Sch {sched}',
{"nominalSize": round(a, 4), "schedule": sched})
# Fallback
return a_txt or "", {}
SHAPE_GROUP_KEY = {
"Angle": "angles",
"Channel": "channels",
"FlatBar": "flatBars",
"IBeam": "iBeams",
"Pipe": "pipes",
"RectangularTube": "rectangularTubes",
"RoundBar": "roundBars",
"RoundTube": "roundTubes",
"SquareBar": "squareBars",
"SquareTube": "squareTubes",
}
def build_catalog(scraped: list[dict]) -> dict:
"""Assemble the final catalog JSON from scraped item dicts."""
materials: dict[tuple, dict] = {}
for item in scraped:
shape = item.get("shape", "")
grade = item.get("grade", "")
if not shape or not grade:
continue
size_str, dims = build_size_and_dims(shape, item)
key = (shape, grade, size_str)
if key not in materials:
mat = {
"type": "Steel",
"grade": grade,
"size": size_str,
"stockItems": [],
}
mat.update(dims)
materials[key] = mat
length = item.get("length_inches")
if length and length > 0:
existing = {si["lengthInches"] for si in materials[key]["stockItems"]}
if round(length, 4) not in existing:
materials[key]["stockItems"].append({
"lengthInches": round(length, 4),
"quantityOnHand": 0,
"supplierOfferings": [{
"supplierName": "Alro Steel",
"partNumber": "",
"supplierDescription": "",
}],
})
# Group by shape key
grouped: dict[str, list] = {v: [] for v in SHAPE_GROUP_KEY.values()}
for (shape, _, _), mat in sorted(materials.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])):
group_key = SHAPE_GROUP_KEY.get(shape)
if group_key:
grouped[group_key].append(mat)
return {
"exportedAt": datetime.now(timezone.utc).isoformat(),
"suppliers": [{"name": "Alro Steel"}],
"cuttingTools": [
{"name": "Bandsaw", "kerfInches": 0.0625, "isDefault": True},
{"name": "Chop Saw", "kerfInches": 0.125, "isDefault": False},
{"name": "Cold Cut Saw", "kerfInches": 0.0625, "isDefault": False},
{"name": "Hacksaw", "kerfInches": 0.0625, "isDefault": False},
],
"materials": grouped,
}
# ═══════════════════════════════════════════════════════════════════════
# Progress management
# ═══════════════════════════════════════════════════════════════════════
def load_progress() -> dict:
if PROGRESS_PATH.exists():
return json.loads(PROGRESS_PATH.read_text(encoding="utf-8"))
return {"completed": [], "items": []}
def save_progress(progress: dict):
PROGRESS_PATH.write_text(json.dumps(progress, indent=2, ensure_ascii=False), encoding="utf-8")
# ═══════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════
async def main():
discover = "--discover" in sys.argv
fresh = "--fresh" in sys.argv
all_grades = "--all-grades" in sys.argv
progress = {"completed": [], "items": []} if fresh else load_progress()
all_items: list[dict] = progress.get("items", [])
done_keys: set[tuple] = {tuple(k) for k in progress.get("completed", [])}
# Build index of saved DimA values per (grade, shape) for partial resume
saved_dim_a: dict[tuple[str, str], set[str]] = {}
if all_items and not fresh:
for item in all_items:
key = (item.get("grade", ""), item.get("shape", ""))
saved_dim_a.setdefault(key, set()).add(item.get("dim_a_val", ""))
log.info("Alro Steel SmartGrid Scraper")
if all_grades:
log.info(" Mode: ALL grades")
else:
log.info(f" Filtering to {len(GRADE_FILTER)} grades: {', '.join(sorted(GRADE_FILTER))}")
if fresh:
log.info(" Fresh start — ignoring saved progress")
elif done_keys:
log.info(f" Resuming: {len(done_keys)} combos done, {len(all_items)} items saved")
if discover:
log.info(" Discovery mode — will scrape first item then stop")
async with Stealth().use_async(async_playwright()) as pw:
browser = await pw.chromium.launch(headless=False)
ctx = await browser.new_context(
viewport={"width": 1280, "height": 900},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
locale="en-US",
timezone_id="America/Indiana/Indianapolis",
)
page = await ctx.new_page()
log.info(f"Navigating to SmartGrid …")
await page.goto(BASE_URL, wait_until="networkidle", timeout=30_000)
await asyncio.sleep(2)
if not await page.query_selector(f"#{ID['main_grid']}"):
log.error("Main grid not found! Saving screenshot.")
SCREENSHOTS_DIR.mkdir(exist_ok=True)
await page.screenshot(path=str(SCREENSHOTS_DIR / "error_no_grid.png"))
await browser.close()
return
log.info("Main grid loaded")
total_scraped = 0
first_item = True
for category in CATEGORIES:
log.info(f"\n{'=' * 60}")
log.info(f" Category: {category}")
log.info(f"{'=' * 60}")
if not await click_category(page, category):
continue
await asyncio.sleep(DELAY)
combos = await scrape_popup_grid(page)
for grade_name, grade_id, shape_name, row_idx, has_btn in combos:
if not has_btn:
continue
# Grade filter
if not all_grades and grade_name not in GRADE_FILTER:
continue
shape_upper = shape_name.upper().strip()
shape_mapped = SHAPE_MAP.get(shape_upper)
if shape_mapped is None:
log.info(f" Skip unmapped shape: {shape_name}")
continue
combo_key = (category, grade_name, shape_name)
if combo_key in done_keys:
log.info(f" Skip (done): {grade_name} / {shape_name}")
continue
log.info(f"\n -- {grade_name} / {shape_name} -> {shape_mapped} --")
if not await click_shape(page, shape_name, row_idx):
await click_back(page)
await asyncio.sleep(DELAY)
continue
await asyncio.sleep(DELAY)
combo_count = 0
def on_item_discovered(item):
nonlocal total_scraped, combo_count
all_items.append(item)
total_scraped += 1
combo_count += 1
progress["items"] = all_items
save_progress(progress)
# Pass already-scraped DimA values so partial combos resume correctly
already = saved_dim_a.get((grade_name, shape_mapped), set())
items = await scrape_dims_panel(
page, grade_name, shape_name, shape_mapped,
save_discovery=first_item or discover,
on_item=on_item_discovered,
scraped_dim_a=already,
)
first_item = False
log.info(f" -> {combo_count} items (total {total_scraped})")
done_keys.add(combo_key)
progress["completed"] = [list(k) for k in done_keys]
save_progress(progress)
await click_back(page)
await asyncio.sleep(DELAY)
if discover:
log.info("\nDiscovery done. Check: scripts/AlroCatalog/screenshots/")
await browser.close()
return
await close_popup(page)
await asyncio.sleep(DELAY)
await browser.close()
# ── Build output ──
log.info(f"\n{'=' * 60}")
log.info(f"Building catalog from {len(all_items)} items …")
catalog = build_catalog(all_items)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(json.dumps(catalog, indent=2, ensure_ascii=False), encoding="utf-8")
log.info(f"Written: {OUTPUT_PATH}")
total_mats = sum(len(v) for v in catalog["materials"].values())
total_stock = sum(len(m["stockItems"]) for v in catalog["materials"].values() for m in v)
log.info(f"Materials: {total_mats}")
log.info(f"Stock items: {total_stock}")
for shape_key, mats in sorted(catalog["materials"].items()):
if mats:
log.info(f" {shape_key}: {len(mats)}")
if __name__ == "__main__":
asyncio.run(main())
+147
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]$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
sc.exe description $name 'CutList bin packing web application' | 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."