From 076b794dda907bd14c9bdcb35884c8200fec8613 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 12:38:30 -0400 Subject: [PATCH] feat(api): accept stock prices and archive cost summaries --- OpenNest.Api/NestRequestPlate.cs | 3 ++ OpenNest.Api/NestResponse.cs | 4 +++ OpenNest.Api/NestRunner.cs | 10 ++++-- OpenNest.Tests/Api/NestRunnerTests.cs | 47 ++++++++++++++++++++++++++- docs/automatic-nesting.md | 17 ++++++++++ docs/nest-file-format.md | 5 +++ 6 files changed, 83 insertions(+), 3 deletions(-) diff --git a/OpenNest.Api/NestRequestPlate.cs b/OpenNest.Api/NestRequestPlate.cs index 6999ad3..31de3e9 100644 --- a/OpenNest.Api/NestRequestPlate.cs +++ b/OpenNest.Api/NestRequestPlate.cs @@ -5,6 +5,9 @@ namespace OpenNest.Api; /// One explicit physical-stock type for a whole nesting job. public class NestRequestPlate { + /// Positive per-sheet cost in common units; omit on every row for area scoring. + public double? Cost { get; init; } + public string Id { get; init; } public Size Size { get; init; } diff --git a/OpenNest.Api/NestResponse.cs b/OpenNest.Api/NestResponse.cs index 6cbd21a..85af3f0 100644 --- a/OpenNest.Api/NestResponse.cs +++ b/OpenNest.Api/NestResponse.cs @@ -44,6 +44,7 @@ public class NestResponse public IReadOnlyList Fulfillment { get; init; } = []; public IReadOnlyList StockUsage { get; init; } = []; public IReadOnlyList PlateStockMappings { get; init; } = []; + public NestCostSummary Costs { get; init; } public Nest Nest { get; init; } public NestRequest Request { get; init; } @@ -86,6 +87,7 @@ public class NestResponse Fulfillment = Fulfillment is null ? [] : new List(Fulfillment), + Costs = Costs, StockUsage = StockUsage is null ? [] : new List(StockUsage), PlateStockMappings = PlateStockMappings is null ? [] @@ -171,6 +173,7 @@ public class NestResponse Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], + Costs = archive.Costs, Nest = nest, Request = request, }; @@ -178,6 +181,7 @@ public class NestResponse private sealed class NestResponseArchiveDto { + public NestCostSummary Costs { get; init; } public int SchemaVersion { get; init; } public int SheetCount { get; init; } public double Utilization { get; init; } diff --git a/OpenNest.Api/NestRunner.cs b/OpenNest.Api/NestRunner.cs index f30e9a5..5e9d265 100644 --- a/OpenNest.Api/NestRunner.cs +++ b/OpenNest.Api/NestRunner.cs @@ -28,6 +28,7 @@ public static class NestRunner if (requestParts.Count == 0) throw new ArgumentException("Request must contain at least one part.", nameof(request)); + var stock = CreateStock(request); var sw = Stopwatch.StartNew(); var parts = IdentifyParts(requestParts); var importedByPath = new Dictionary(StringComparer.Ordinal); @@ -82,7 +83,6 @@ public static class NestRunner }); } - var stock = CreateStock(request); var engineName = request.Engine ?? ResolvePlacementStrategy(request); var jobProgress = progress == null ? null : new JobProgressBridge(progress); var result = NestPipeline.Run(new NestPipelineRequest( @@ -91,6 +91,9 @@ public static class NestRunner // API returns a detached proposal, not an acceptance/commit to a caller's nest. // Invalid but representable proposals retain every pose and carry explicit validation status. var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) }; + if (request.Plates != null) + nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption + { Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList(); foreach (var item in items) nest.Drawings.Add(item.Drawing); foreach (var proposed in result.Plates) @@ -144,6 +147,7 @@ public static class NestRunner Violations = result.Violations, Fulfillment = fulfillment, StockUsage = usage, + Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null, PlateStockMappings = result.Plates.Select((value, index) => new NestPlateStockMapping(index, value.Stock.Id)).ToArray(), Nest = nest, @@ -209,11 +213,13 @@ public static class NestRunner plate.Quantity, plate.PartSpacing, plate.EdgeSpacing, - plate.Quadrant + plate.Quadrant, + plate.Cost ) ); } + NestJobValidator.ValidateStockCosts(stock); return stock; } diff --git a/OpenNest.Tests/Api/NestRunnerTests.cs b/OpenNest.Tests/Api/NestRunnerTests.cs index 9076b06..881decd 100644 --- a/OpenNest.Tests/Api/NestRunnerTests.cs +++ b/OpenNest.Tests/Api/NestRunnerTests.cs @@ -4,14 +4,59 @@ using System.Linq; using System.Threading.Tasks; using OpenNest.Api; using OpenNest.Converters; +using OpenNest.Engine.Jobs; using OpenNest.Geometry; using OpenNest.IO; -using OpenNest.Engine.Jobs; namespace OpenNest.Tests.Api; public class NestRunnerTests { + [Fact] + public async Task ExplicitPricesSelectCheaperStockAndSurviveArchive() + { + var path = CreateTempSquareDxf(4, 4); + var archive = Path.ChangeExtension(path, ".nestquote"); + try + { + var response = await NestRunner.RunAsync(new NestRequest + { + Engine = "Rectangles", + Parts = [new NestRequestPart { DxfPath = path, Quantity = 1 }], + Plates = [new NestRequestPlate { Id = "small", Size = new Size(10, 10), Quantity = 1, Cost = 50 }, + new NestRequestPlate { Id = "large", Size = new Size(12, 12), Quantity = 1, Cost = 7 }], + }); + Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus); + Assert.Equal("large", Assert.Single(response.PlateStockMappings).StockId); + Assert.Equal("supplied-cost", response.Costs.Basis); + Assert.Equal(7, response.Costs.GrossTotal); + Assert.Equal(7, response.Costs.NetScore); + Assert.Equal(0, response.Costs.SalvageCredit); + await response.SaveAsync(archive); + var reopened = await NestResponse.LoadAsync(archive); + Assert.Equal(7, reopened.Costs.GrossTotal); + Assert.Equal(new[] { 50d, 7d }, reopened.Nest.PlateOptions.Select(p => p.Cost)); + Assert.Equal(7, reopened.Request.Plates[1].Cost); + } + finally + { + File.Delete(path); + File.Delete(archive); + } + } + + [Fact] + public async Task MixedCostsFailBeforeImport() + { + var error = await Assert.ThrowsAsync(() => NestRunner.RunAsync(new NestRequest + { + Parts = [new NestRequestPart { DxfPath = "missing.dxf", Quantity = 1 }], + Plates = [new NestRequestPlate { Id = "priced", Size = new Size(10, 10), Cost = 2 }, + new NestRequestPlate { Id = "missing", Size = new Size(12, 12) }], + })); + Assert.Contains("Missing: missing", error.Message); + } + [Fact] public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId() { diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index c25db52..dd69713 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -98,3 +98,20 @@ Response archive schema 3 persists validation status and violations. Older archi ## Verification `dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj` exercises Console, MCP and API rejection/override, cancellation, physical-sheet settings, response IDs and archive round-trips. Its current target matches MCP's `net8.0-windows` marker but does not use WindowsDesktop and executes on Linux as well as Windows. It is included in the solution and Windows test workflow. Desktop interaction tests remain in Windows-only `OpenNest.WinForms.Tests`. + +## Stock costs + +API `NestRequestPlate.Cost` is an optional per-physical-sheet cost in caller-consistent +units. Omit every cost for area scoring, or supply strictly positive finite costs +for every available row. Explicit zero and mixed priced/unpriced available stock +are rejected before import. Quantity-zero rows do not select the cost mode. + +Validated proposals expose `Costs` (`Basis`, `Stock`, `GrossTotal`, `SalvageCredit`, +`NetScore`) and persist them in quote archives. `area` identifies fallback scoring; +`supplied-cost` identifies explicit prices. API salvage is disabled, so two sheets +costing 25 each report gross and net totals of 50 with zero salvage credit. Totals +exclude penalties for unplaced demand. Invalid proposals have no cost summary; +overflow fails instead of becoming a score. + +Returned nests preserve offered stock prices. These are cost-aware heuristics, +not global cost optimizers; validation and fulfillment remain required. diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 483ed31..45c6f71 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -91,3 +91,8 @@ The existing `plateOptions` entries retain width, length and `cost`; no new `.ne format version is required. Legacy zero cost means unspecified, not a free sheet. Positive costs are generic per-sheet values in caller-consistent units. Solving requires all available options to be priced or all to be unspecified. + +API returned nests retain offered options, excluding rows with zero available +quantity because `.nest` options do not retain inventory. Options are offers, not +a per-sheet purchase ledger. API quote archives additionally retain request costs +and validated proposal cost summaries; old archives load with `Costs == null`.