diff --git a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs index 37b8f3b..fd0f033 100644 --- a/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs +++ b/OpenNest.FrontEnd.Tests/McpWholeJobTests.cs @@ -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 diff --git a/OpenNest.Mcp/SheetStockInput.cs b/OpenNest.Mcp/SheetStockInput.cs index 2ccf5cf..c7e9a70 100644 --- a/OpenNest.Mcp/SheetStockInput.cs +++ b/OpenNest.Mcp/SheetStockInput.cs @@ -3,6 +3,9 @@ namespace OpenNest.Mcp; /// Request-local physical inventory for one whole-job solve. public sealed class SheetStockInput { + /// Positive per-sheet cost in common units; omit on every row for area scoring. + public double? Cost { get; set; } + public double Width { get; set; } public double Length { get; set; } public int Quantity { get; set; } diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs index 096705b..3225d5f 100644 --- a/OpenNest.Mcp/Tools/InputTools.cs +++ b/OpenNest.Mcp/Tools/InputTools.cs @@ -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); diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 6ecfb12..39bf840 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -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(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(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); } diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index dd69713..6cd8664 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -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. diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 45c6f71..e6032eb 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -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`.