mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-12 00:54:13 -04:00
feat(mcp): support priced stock and preserve accepted job data
This commit is contained in:
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,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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user