From 0c9945548906fb789913abc70de39fe650a88134 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 12:38:48 -0400 Subject: [PATCH] feat(ui): validate Auto Nest stock prices and report costs --- .../Forms/AutoNestFormStockOptionsTests.cs | 63 ++++++++++++++++++- OpenNest/Forms/AutoNestForm.cs | 27 ++++++++ OpenNest/Forms/MainForm.cs | 4 ++ docs/automatic-nesting.md | 53 +++++++++------- 4 files changed, 123 insertions(+), 24 deletions(-) diff --git a/OpenNest.WinForms.Tests/Forms/AutoNestFormStockOptionsTests.cs b/OpenNest.WinForms.Tests/Forms/AutoNestFormStockOptionsTests.cs index d6d5a5d..1c198a5 100644 --- a/OpenNest.WinForms.Tests/Forms/AutoNestFormStockOptionsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutoNestFormStockOptionsTests.cs @@ -10,6 +10,64 @@ namespace OpenNest.WinForms.Tests.Forms; public class AutoNestFormStockOptionsTests { + [Theory] + [InlineData("invalid", false)] + [InlineData("-1", false)] + [InlineData("NaN", false)] + [InlineData("27", true)] + public void PendingCostEditsAreValidatedBeforePublishing(string text, bool valid) + { + RunSta(() => + { + using var form = new AutoNestForm(new Nest()); + form.LoadPlateOptions(new List { new() { Width = 48, Length = 96, Cost = 5 } }, 0.5); + var grid = GetGrid(form); + ((TabControl)form.Controls.Find("tabControl", true).Single()).SelectedIndex = 1; + form.Show(); + grid.Focus(); + grid.CurrentCell = grid.Rows[0].Cells[1]; + Assert.True(grid.BeginEdit(false)); + grid.EditingControl.Text = text; + Assert.Equal(valid, form.TryGetPlateOptions(out var options, out var error)); + if (valid) + Assert.Equal(27, Assert.Single(options).Cost); + else + { + Assert.Empty(options); + Assert.Contains("cost", error); + } + grid.CancelEdit(); + }); + } + + [Theory] + [InlineData(0, 0, true)] + [InlineData(5, 8, true)] + [InlineData(5, 0, false)] + [InlineData(0, 5, false)] + [InlineData(-1, -1, false)] + [InlineData(double.NaN, 5, false)] + [InlineData(double.PositiveInfinity, 5, false)] + public void CostRowsRequireConsistentFiniteValues(double first, double second, bool valid) + { + RunSta(() => + { + using var form = new AutoNestForm(new Nest()); + form.LoadPlateOptions(new List + { + new() { Width = 48, Length = 96, Cost = first }, + new() { Width = 60, Length = 120, Cost = second }, + }, 0.5); + Assert.Equal(valid, form.TryGetPlateOptions(out var options, out var error)); + if (!valid) + { + Assert.Empty(options); + Assert.Contains("cost", error); + } + AssertNewRow(GetGrid(form)); + }); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -62,7 +120,7 @@ public class AutoNestFormStockOptionsTests Assert.True(rows.AllowNew); var added = rows.AddNew()!; SetValue(added, "Size", "84 x 168"); - SetValue(added, "Cost", 123.5); + SetValue(added, "Cost", loadSavedOptions ? 123.5 : 0); ((ICancelAddNew)rows).EndNew(rows.Count - 1); var options = form.GetPlateOptions(); @@ -70,7 +128,7 @@ public class AutoNestFormStockOptionsTests var option = options.Last(); Assert.Equal(84, option.Width); Assert.Equal(168, option.Length); - Assert.Equal(123.5, option.Cost); + Assert.Equal(loadSavedOptions ? 123.5 : 0, option.Cost); AssertNewRow(grid); }); } @@ -128,6 +186,7 @@ public class AutoNestFormStockOptionsTests var rows = Assert.IsAssignableFrom(grid.DataSource); var row = rows.AddNew()!; SetValue(row, "Size", invalid); + SetValue(row, "Cost", 25); ((ICancelAddNew)rows).EndNew(rows.Count - 1); Assert.False(form.TryGetPlateOptions(out var options, out var error)); diff --git a/OpenNest/Forms/AutoNestForm.cs b/OpenNest/Forms/AutoNestForm.cs index bd297f0..667adda 100644 --- a/OpenNest/Forms/AutoNestForm.cs +++ b/OpenNest/Forms/AutoNestForm.cs @@ -152,6 +152,7 @@ namespace OpenNest.Forms { DataPropertyName = "Cost", HeaderText = "Cost", + ToolTipText = "Per physical sheet, in one common unit. Use zero on every row for area scoring, or positive costs on every row. Salvage credits cost proportionally; set salvage to zero for purchase totals.", Width = 70, AutoSizeMode = DataGridViewAutoSizeColumnMode.None, } @@ -235,6 +236,15 @@ namespace OpenNest.Forms } } + if (plateGrid.IsCurrentCellInEditMode && plateGrid.CurrentCell != null + && plateGrid.Columns[plateGrid.CurrentCell.ColumnIndex].DataPropertyName == "Cost" + && plateGrid.EditingControl != null + && !ValidCost(plateGrid.EditingControl.Text)) + { + error = $"Invalid stock cost in row {plateGrid.CurrentCell.RowIndex + 1}. Enter a finite positive cost, or zero on every row."; + return false; + } + bool committed; try { @@ -269,6 +279,14 @@ namespace OpenNest.Forms return false; } + if (!double.IsFinite(item.Cost) || item.Cost < 0 + || validated.Count > 0 && (validated[0].Cost == 0) != (item.Cost == 0)) + { + error = $"Invalid stock cost in row {index + 1}. Use finite positive costs on every active row, or zero on every row."; + if (index < plateGrid.Rows.Count) + plateGrid.Rows[index].ErrorText = error; + return false; + } if (index < plateGrid.Rows.Count) plateGrid.Rows[index].ErrorText = ""; validated.Add(new PlateOption { Width = width, Length = length, Cost = item.Cost }); @@ -378,8 +396,17 @@ namespace OpenNest.Forms UpdateSummary(); } + private static bool ValidCost(string text) => + double.TryParse(text, out var cost) && double.IsFinite(cost) && cost >= 0; + private void PlateGrid_CellValidating(object sender, DataGridViewCellValidatingEventArgs e) { + if (plateGrid.Columns[e.ColumnIndex].DataPropertyName == "Cost") + { + e.Cancel = !plateGrid.Rows[e.RowIndex].IsNewRow && !ValidCost(e.FormattedValue?.ToString()); + plateGrid.Rows[e.RowIndex].ErrorText = e.Cancel ? "Enter a finite positive cost, or zero on every row." : ""; + return; + } if (plateGrid.Columns[e.ColumnIndex].DataPropertyName != "Size") return; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 938e7fc..876ab35 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -1494,6 +1494,7 @@ namespace OpenNest.Forms allowInvalid = true; } + var costs = result.IsValid && result.CanKeep ? NestCostSummary.FromAccepted(result) : null; var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager, allowInvalid, cts.Token); if (applied.Count > 0) @@ -1509,6 +1510,9 @@ namespace OpenNest.Forms target.OverlapDisplay = OverlapDisplayMode.Both; await target.CheckOverlapsAsync(); } + if (costs?.Basis == "supplied-cost") + MessageBox.Show(this, $"Gross sheet cost: {costs.GrossTotal:G}\nEstimated salvage credit: {costs.SalvageCredit:G}\nNet score: {costs.NetScore:G}", + "Auto Nest Cost", MessageBoxButtons.OK, MessageBoxIcon.Information); if (result.Status != NestJobStatus.Complete) MessageBox.Show(this, $"{engineName} could not place every part ({result.StopReason}).", "Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Information); diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 6cd8664..49db075 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -101,29 +101,38 @@ Response archive schema 3 persists validation status and violations. Older archi ## 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. - +Auto Nest Stock Options accepts a generic **per physical sheet** Cost. Enter positive +finite costs on every active row in one common unit (purchase price, area, or another +consistent scalar). All-zero rows retain legacy area scoring: zero means unspecified, +not free stock. Negative/nonfinite costs and mixed zero/positive rows are rejected +before solving. The blank add row is ignored. 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. +`length` and positive finite `quantity`. API `NestRequestPlate.Cost` uses the same +nullable contract: omit every cost for area scoring, or supply strictly positive +finite values for every available row. Explicit zero is invalid in these requests. +Engine stock with quantity zero is unavailable and does not select the cost mode. +MCP continues to reject duplicate sizes; API/engine stock IDs keep independent inventory. + +With prices, gross sheet cost is the supplied value. Salvage is still computed from +the largest qualifying material-edge offcut; net cost is +`Cost * (NetSheetArea / SheetArea)`. Set desktop salvage rate to zero for purchase-price +comparisons. Entering prices preserves the selected salvage settings. MCP and API +currently disable salvage. Without prices, scores retain their existing area units +and tie ordering; fixed strategies retain their legacy no-salvage area tie-break. + +Desktop reports gross cost, estimated salvage credit and net score. MCP reports +`basis`, each stock's used physical count, supplied cost and gross subtotal, plus +gross total, estimated salvage credit and net score. API exposes these under `Costs` +(`Basis`, `Stock`, `GrossTotal`, `SalvageCredit`, `NetScore`) and persists them in +quote archives. For example, two sheets costing 25 each report gross total 50; +with salvage disabled, credit is 0 and net score is 50. `area` identifies fallback +scoring; `supplied-cost` identifies explicit prices. Totals cover the returned proposal, +not previously occupied session sheets, and exclude penalties for unplaced demand. +Invalid API proposals have no cost summary. Overflow fails instead of becoming a score. 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. +save/reopen preserves them and existing nest metadata. Rejected/cancelled proposals +change neither requirements nor options. Inventory remains request-local; saved +options are offers, not a per-sheet purchase ledger. These are cost-aware heuristics, +not global cost optimizers; validation and fulfillment remain required.