feat(api): accept stock prices and archive cost summaries

This commit is contained in:
aj committed 2026-10-10 12:38:30 -04:00
1 parent 5dbf887b68
commit 076b794dda
6 files changed
+83 -3

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate
{
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; init; }
public string Id { get; init; }
public Size Size { get; init; }
+4
View File
@@ -44,6 +44,7 @@ public class NestResponse
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> 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<NestPartFulfillment>(Fulfillment),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(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; }
+8 -2
View File
@@ -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<string, Drawing>(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;
}
+46 -1
View File
@@ -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<ArgumentException>(() => 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()
{
+17
View File
@@ -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.
+5
View File
@@ -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`.