Compare commits

...

35 Commits

Author SHA1 Message Date
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
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
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
dac2833dd1 chore: Remove ExportData script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:01:22 -05:00
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
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
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
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
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
2fdf006a8e docs: Update CLAUDE.md with optimization persistence and Results tab
Document new Job entity fields, serialization DTOs, JobService
optimization methods, and merged Results tab in Edit page.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:12:28 -05:00
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
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
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
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
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
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
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
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
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
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
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
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
2a94ad63cb fix: Correct indentation in MaterialService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 23:17:58 -05:00
b0a9d7fdcc docs: Add descriptive intro text to index pages
Adds brief explanatory paragraphs to Jobs, Materials, and Stock index
pages to help users understand each section's purpose.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 16:54:05 -05:00
17f16901ef feat: Add full REST API with controllers, DTOs, and service layer
Add controllers for suppliers, stock items, jobs, cutting tools, and
packing. Refactor MaterialsController to use MaterialService with
dimension-aware CRUD, search, and bulk operations. Extract DTOs into
dedicated files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 16:53:53 -05:00
165 changed files with 31979 additions and 2892 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -1,20 +0,0 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,21 +0,0 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"
}
}

246
CLAUDE.md
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 |

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--;

444
CutList.Mcp/ApiClient.cs Normal file
View File

@@ -0,0 +1,444 @@
using System.Net.Http.Json;
namespace CutList.Mcp;
/// <summary>
/// Typed HTTP client for calling the CutList.Web REST API.
/// </summary>
public class ApiClient
{
private readonly HttpClient _http;
public ApiClient(HttpClient http)
{
_http = http;
}
#region Suppliers
public async Task<List<ApiSupplierDto>> GetSuppliersAsync(bool includeInactive = false)
{
var url = $"api/suppliers?includeInactive={includeInactive}";
return await _http.GetFromJsonAsync<List<ApiSupplierDto>>(url) ?? [];
}
public async Task<ApiSupplierDto?> CreateSupplierAsync(string name, string? contactInfo, string? notes)
{
var response = await _http.PostAsJsonAsync("api/suppliers", new { Name = name, ContactInfo = contactInfo, Notes = notes });
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiSupplierDto>();
}
#endregion
#region Materials
public async Task<List<ApiMaterialDto>> GetMaterialsAsync(string? shape = null, bool includeInactive = false)
{
var url = $"api/materials?includeInactive={includeInactive}";
if (!string.IsNullOrEmpty(shape))
url += $"&shape={Uri.EscapeDataString(shape)}";
return await _http.GetFromJsonAsync<List<ApiMaterialDto>>(url) ?? [];
}
public async Task<ApiMaterialDto?> CreateMaterialAsync(string shape, string? size, string? description,
string? type, string? grade, Dictionary<string, decimal>? dimensions)
{
var body = new
{
Shape = shape,
Size = size,
Description = description,
Type = type,
Grade = grade,
Dimensions = dimensions
};
var response = await _http.PostAsJsonAsync("api/materials", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiMaterialDto>();
}
public async Task<List<ApiMaterialDto>> SearchMaterialsAsync(string shape, decimal targetValue, decimal tolerance)
{
var response = await _http.PostAsJsonAsync("api/materials/search", new
{
Shape = shape,
TargetValue = targetValue,
Tolerance = tolerance
});
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<ApiMaterialDto>>() ?? [];
}
#endregion
#region Stock Items
public async Task<List<ApiStockItemDto>> GetStockItemsAsync(int? materialId = null, bool includeInactive = false)
{
var url = $"api/stock-items?includeInactive={includeInactive}";
if (materialId.HasValue)
url += $"&materialId={materialId.Value}";
return await _http.GetFromJsonAsync<List<ApiStockItemDto>>(url) ?? [];
}
public async Task<ApiStockItemDto?> CreateStockItemAsync(int materialId, string length, string? name, int quantityOnHand, string? notes)
{
var body = new
{
MaterialId = materialId,
Length = length,
Name = name,
QuantityOnHand = quantityOnHand,
Notes = notes
};
var response = await _http.PostAsJsonAsync("api/stock-items", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiStockItemDto>();
}
#endregion
#region 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)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/suppliers/{supplierId}/offerings") ?? [];
}
public async Task<List<ApiOfferingDto>> GetOfferingsForStockItemAsync(int stockItemId)
{
return await _http.GetFromJsonAsync<List<ApiOfferingDto>>($"api/stock-items/{stockItemId}/offerings") ?? [];
}
public async Task<ApiOfferingDto?> CreateOfferingAsync(int supplierId, int stockItemId,
string? partNumber, string? supplierDescription, decimal? price, string? notes)
{
var body = new
{
StockItemId = stockItemId,
PartNumber = partNumber,
SupplierDescription = supplierDescription,
Price = price,
Notes = notes
};
var response = await _http.PostAsJsonAsync($"api/suppliers/{supplierId}/offerings", body);
if (response.StatusCode == System.Net.HttpStatusCode.Conflict)
{
var error = await response.Content.ReadAsStringAsync();
throw new ApiConflictException(error);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ApiOfferingDto>();
}
#endregion
}
/// <summary>
/// Thrown when the API returns 409 Conflict (duplicate resource).
/// </summary>
public class ApiConflictException : Exception
{
public ApiConflictException(string message) : base(message) { }
}
#region API Response DTOs 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
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class ApiMaterialDto
{
public int Id { get; set; }
public string Shape { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string? Grade { get; set; }
public string Size { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsActive { get; set; }
public ApiMaterialDimensionsDto? Dimensions { get; set; }
}
public class ApiMaterialDimensionsDto
{
public string DimensionType { get; set; } = string.Empty;
public Dictionary<string, decimal> Values { get; set; } = new();
}
public class ApiStockItemDto
{
public int Id { get; set; }
public int MaterialId { get; set; }
public string MaterialName { get; set; } = string.Empty;
public decimal LengthInches { get; set; }
public string LengthFormatted { get; set; } = string.Empty;
public string? Name { get; set; }
public int QuantityOnHand { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
public class ApiOfferingDto
{
public int Id { get; set; }
public int SupplierId { get; set; }
public string? SupplierName { get; set; }
public int StockItemId { get; set; }
public string? MaterialName { get; set; }
public decimal? LengthInches { get; set; }
public string? LengthFormatted { get; set; }
public string? PartNumber { get; set; }
public string? SupplierDescription { get; set; }
public decimal? Price { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; }
}
#endregion

View File

@@ -2,11 +2,11 @@
<ItemGroup>
<ProjectReference Include="..\CutList.Core\CutList.Core.csproj" />
<ProjectReference Include="..\CutList.Web\CutList.Web.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.4" />
<PackageReference Include="ModelContextProtocol" Version="0.7.0-preview.1" />
</ItemGroup>

File diff suppressed because it is too large Load Diff

619
CutList.Mcp/JobTools.cs Normal file
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

View File

@@ -1,14 +1,15 @@
using CutList.Web.Data;
using Microsoft.EntityFrameworkCore;
using CutList.Mcp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
// Add DbContext for inventory tools
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=CutListDb;Trusted_Connection=True;MultipleActiveResultSets=true"));
// Register HttpClient for API calls to CutList.Web
builder.Services.AddHttpClient<ApiClient>(client =>
{
client.BaseAddress = new Uri("http://localhost:5009");
});
builder.Services
.AddMcpServer()

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" />

View File

@@ -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

View File

@@ -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");
}

File diff suppressed because it is too large Load Diff

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