feat(ui): validate Auto Nest stock prices and report costs

This commit is contained in:
aj committed 2026-10-10 12:38:48 -04:00
1 parent c34ac2bc0e
commit 0c99455489
4 files changed
+123 -24

No files matched your search

@@ -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<PlateOption> { 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<PlateOption>
{
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<IBindingList>(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));
+27
View File
@@ -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;
+4
View File
@@ -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);
+31 -22
View File
@@ -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.