feat(mcp): support priced stock and preserve accepted job data

This commit is contained in:
aj committed 2026-10-10 12:38:36 -04:00
1 parent 076b794dda
commit c34ac2bc0e
6 files changed
+110 -8

No files matched your search

@@ -38,6 +38,54 @@ public class McpWholeJobTests : IDisposable
string engine, bool noNew = false, CancellationToken token = default) =>
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
[Fact]
public void AcceptedPricesAndRequirementsSurviveSaveWithoutLosingMetadata()
{
var session = Session();
session.Nest.Customer = "Cost customer";
session.Nest.Notes = "Keep these notes";
session.Nest.Units = Units.Inches;
session.Nest.SalvageRate = 0.25;
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 2, Cost = 50 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 2, Cost = 7 }]);
Assert.Contains("Whole job complete", output);
Assert.Contains("gross total=7", output);
Assert.Contains("net score=7", output);
Assert.Contains("supplied-cost", output);
Assert.All(session.AllDrawings(), d => Assert.Equal(1, d.Quantity.Required));
var path = Path.Combine(directory, "priced.nest");
new InputTools(session).SaveNest(path);
var reopened = new NestReader(path).Read();
Assert.Equal("Cost customer", reopened.Customer);
Assert.Equal("Keep these notes", reopened.Notes);
Assert.Equal(Units.Inches, reopened.Units);
Assert.Equal(0.25, reopened.SalvageRate);
Assert.Equal(new[] { 50d, 7d }, reopened.PlateOptions.Select(o => o.Cost));
Assert.All(reopened.Drawings, d => Assert.Equal(1, d.Quantity.Required));
Assert.Equal(2, reopened.Plates.Sum(p => p.Parts.Count));
}
[Theory]
[InlineData(null)]
[InlineData(0d)]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void InvalidCostsDoNotMutateSession(double? cost)
{
var session = Session();
var quantities = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
var output = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", "Rectangles", sheets:
[new SheetStockInput { Width = 10, Length = 10, Quantity = 1, Cost = 5 },
new SheetStockInput { Width = 12, Length = 12, Quantity = 1, Cost = cost }]);
Assert.Contains("Error:", output);
Assert.Contains("sheets[1]", output);
Assert.Empty(session.GetPlate(0).Parts);
Assert.Empty(session.Nest.PlateOptions);
Assert.Equal(quantities, session.AllDrawings().Select(d => d.Quantity.Required));
}
[Fact]
public void FiniteSheetInputsReachEngineWithTemplateSettings()
{
@@ -398,6 +446,7 @@ public class McpWholeJobTests : IDisposable
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
{
var session = Session();
var required = session.AllDrawings().Select(d => d.Quantity.Required).ToArray();
using var cts = new CancellationTokenSource();
var engine = "JobCancel-" + Guid.NewGuid();
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
@@ -406,6 +455,7 @@ public class McpWholeJobTests : IDisposable
Assert.Single(session.AllPlates());
Assert.Empty(session.GetPlate(0).Parts);
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
Assert.Equal(required, session.AllDrawings().Select(d => d.Quantity.Required));
}
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
+3
View File
@@ -3,6 +3,9 @@ namespace OpenNest.Mcp;
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
public sealed class SheetStockInput
{
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; set; }
public double Width { get; set; }
public double Length { get; set; }
public int Quantity { get; set; }
+18 -2
View File
@@ -69,8 +69,24 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the nest (optional)")] string name = null
)
{
var nest = new Nest();
nest.Name = name ?? Path.GetFileNameWithoutExtension(path);
var source = _session.Nest;
var nest = new Nest
{
Name = name ?? (string.IsNullOrWhiteSpace(source?.Name) ? Path.GetFileNameWithoutExtension(path) : source.Name),
Customer = source?.Customer ?? "",
Notes = source?.Notes ?? "",
AssistGas = source?.AssistGas ?? "",
Status = source?.Status ?? NestStatus.Quote,
MadeBy = source?.MadeBy ?? "",
Thickness = source?.Thickness ?? 0,
Material = source?.Material ?? new Material(),
Units = source?.Units ?? default,
DateCreated = source?.DateCreated ?? default,
DateLastModified = source?.DateLastModified ?? default,
PlateDefaults = source?.PlateDefaults ?? new Nest.PlateSettings(),
PlateOptions = source?.PlateOptions ?? new(),
SalvageRate = source?.SalvageRate ?? 0.5,
};
foreach (var drawing in _session.AllDrawings())
nest.Drawings.Add(drawing);
+20 -2
View File
@@ -344,7 +344,7 @@ namespace OpenNest.Mcp.Tools
[Description(JobEngines)] string engine = null,
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
CancellationToken cancellationToken = default,
[Description("Optional finite physical sheet stock: width, length and available quantity per size. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
[Description("Optional finite physical sheet stock: width, length, available quantity and optional positive per-sheet cost in common units. Specify costs for every row or omit all. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
)
{
var template = _session.GetPlate(plateIndex);
@@ -429,6 +429,8 @@ namespace OpenNest.Mcp.Tools
if (result.Plates.Count > limit)
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
var costSummary = NestCostSummary.FromAccepted(result);
// Build the entire target map before attaching a single part. Indexes in a mixed
// stock proposal are solver ordinals, not indexes into the session's empty plates.
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
@@ -462,7 +464,18 @@ namespace OpenNest.Mcp.Tools
_session.Plates.Add(target);
target.Parts.AddRange(proposed.Parts);
}
foreach (var item in parsed.items)
item.Drawing.Quantity.Required = item.Quantity;
if (sheets != null)
{
_session.Nest ??= new Nest();
_session.Nest.PlateOptions = offeredStock.stock.Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
}
var lines = new StringBuilder();
lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
foreach (var usage in costSummary.Stock)
lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
if (sheets != null)
{
@@ -486,15 +499,20 @@ namespace OpenNest.Mcp.Tools
if (sheets.Length == 0)
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
var stock = new List<NestPlateStock>(sheets.Length);
var priced = sheets.Any(s => s?.Cost != null);
long total = 0;
for (var i = 0; i < sheets.Length; i++)
{
var row = sheets[i];
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|| !double.IsFinite(row.Width * row.Length)
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
if (row.Cost is double cost && (!double.IsFinite(cost) || cost <= 0)
|| priced && row.Cost == null)
return (null, 0, $"Error: sheets[{i}] requires a finite positive cost; specify costs on every row or omit all. Nothing committed.");
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
if (duplicate >= 0)
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
@@ -502,7 +520,7 @@ namespace OpenNest.Mcp.Tools
if (total > int.MaxValue)
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
template.PartSpacing, template.EdgeSpacing, template.Quadrant));
template.PartSpacing, template.EdgeSpacing, template.Quadrant, row.Cost));
}
return (stock, total, null);
}
+12
View File
@@ -115,3 +115,15 @@ 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.
MCP `autonest_job` accepts optional `cost` on each `sheets` row, alongside `width`,
`length` and positive finite `quantity`. Supply positive finite costs on every row
or omit all costs. MCP continues to reject duplicate sizes and disables salvage.
Its result reports the cost basis, used physical counts, supplied costs, gross
subtotals, gross total, estimated salvage credit and net score for the proposal.
An accepted MCP solve stores requested drawing quantities and offered stock prices;
save/reopen preserves them and existing nest metadata, plate defaults and salvage
settings. Rejected/cancelled proposals change neither requirements nor options.
Inventory remains request-local; saved options are offers, not a purchase ledger.
+7 -4
View File
@@ -92,7 +92,10 @@ 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`.
Desktop and accepted MCP/API jobs retain offered options (API rows with zero available
quantity are excluded because `.nest` options do not retain inventory). MCP save also preserves
loaded job metadata, plate defaults and salvage settings, and accepted whole-job
requirements are stored on drawings. Options do not persist finite inventory or
associate a historical purchase price with each physical sheet. API quote archives
additionally retain request costs and validated proposal cost summaries; old archives
without summaries load with `Costs == null`.