From be01124a0fb973f8ec64342f6d62734ffd239ff5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 25 Oct 2026 16:40:36 -0400 Subject: [PATCH] feat(autonest): rank MaterialLedger stock and mark substitutes MaterialLedger's /api/stock/opennest now resolves an OpenNest material and grade through ranked acceptance rules. The client reads the new spec rank, family, grade and finish and the item rank; an older ledger that sends none of them deserializes as rank 1 with no label. LedgerStockMapper groups drops only by identical size and spec, keeps the ledger's order in rows and in each row's ItemCodes, and orders rows by rank. Auto Nest's Source column shows the row's grade and finish ("A1011 P&O"); substitutes (rank above 1) read "A1011 P&O for HR" in amber, name the substitution in their tooltip and still load ticked. The engine still sees no rank. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/MaterialLedger/LedgerStock.cs | 21 ++++- .../MaterialLedger/LedgerStockMapper.cs | 65 ++++++++++---- OpenNest.Tests/Data/LedgerStockMapperTests.cs | 89 +++++++++++++++++-- .../Data/MaterialLedgerClientTests.cs | 42 +++++++++ .../Forms/AutoNestFormLedgerStockTests.cs | 62 +++++++++++++ OpenNest/Forms/AutoNestForm.cs | 56 +++++++++--- docs/automatic-nesting.md | 32 +++++-- 7 files changed, 323 insertions(+), 44 deletions(-) diff --git a/OpenNest.Data/MaterialLedger/LedgerStock.cs b/OpenNest.Data/MaterialLedger/LedgerStock.cs index 406c67dc..6063daa8 100644 --- a/OpenNest.Data/MaterialLedger/LedgerStock.cs +++ b/OpenNest.Data/MaterialLedger/LedgerStock.cs @@ -9,12 +9,24 @@ public sealed record LedgerStockResponse( IReadOnlyList? Specs, IReadOnlyList? Items); -public sealed record LedgerStockSpec(long MaterialSpecId, string DisplayName, decimal ThicknessIn, string? GaugeLabel); +/// +/// Mirrors ML StockApi.OpenNestStockSpecDto. is the spec's place in ML's +/// acceptance rule (0 an exact mapping, 1 preferred, higher a substitute); an older ledger sends +/// neither it nor the catalog names, so they stay null and the spec counts as rank 1. +/// +public sealed record LedgerStockSpec(long MaterialSpecId, string DisplayName, decimal ThicknessIn, string? GaugeLabel) +{ + public int? Rank { get; init; } + public string? MaterialFamily { get; init; } + public string? Grade { get; init; } + public string? Finish { get; init; } +} /// /// Mirrors ML StockApi.StockItemDto. Sizes are inches with the short /// side; is free after active holds. and -/// stay strings so a new ML enum value still deserializes. +/// stay strings so a new ML enum value still deserializes. +/// is its spec's rank, null from an older ledger. /// public sealed record LedgerStockItem( string ItemCode, @@ -28,7 +40,10 @@ public sealed record LedgerStockItem( string LocationName, string? ParentLocationCode, decimal? CostPerUnit, - DateTime LastObservedAtUtc); + DateTime LastObservedAtUtc) +{ + public int? Rank { get; init; } +} /// ML InventoryItemKind names. public static class LedgerItemKinds diff --git a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs index a8eeb983..5454053e 100644 --- a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs +++ b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs @@ -6,7 +6,7 @@ namespace OpenNest.Data.MaterialLedger; /// /// One Auto Nest stock row built from ledger items. is in nest units with /// Width the short side. lists one code per unit of -/// (a bundle repeats its code), in the order later steps assign them. +/// (a bundle repeats its code), in ledger order, which later steps assign. /// public sealed record LedgerStockRow( string Id, @@ -20,6 +20,18 @@ public sealed record LedgerStockRow( /// Each item code's ledger location code, for display only. public IReadOnlyDictionary ItemLocations { get; init; } = new Dictionary(StringComparer.Ordinal); + + /// + /// The row's spec rank in ML's acceptance rule: 0 an exact mapping, 1 the preferred entry, + /// higher a substitute. An older ledger sends none, which counts as 1. + /// + public int Rank { get; init; } = 1; + + /// The catalog grade and finish the row is cut from, e.g. "A1011 P&O"; null when the ledger sends neither. + public string? Label { get; init; } + + /// True for stock ML accepts in place of the preferred material (rank above 1). + public bool IsSubstitute => Rank > 1; } /// The rows offered to Auto Nest and the counts of ledger items left out. @@ -30,8 +42,9 @@ public sealed record LedgerStockMapping( { public int SheetRows => Rows.Count(r => !r.IsDrop); public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity); + public int SubstituteRows => Rows.Count(r => r.IsSubstitute); - /// E.g. "Ledger: 3 sheet sizes, 7 drops (2 skipped: unknown shape)". + /// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)". public string Summary { get @@ -42,6 +55,8 @@ public sealed record LedgerStockMapping( if (SkippedTooSmall > 0) skipped.Add($"{SkippedTooSmall} too small for any part"); var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}"; + if (SubstituteRows > 0) + text += $", {Plural(SubstituteRows, "substitute row")}"; return skipped.Count == 0 ? text : $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})"; @@ -53,7 +68,8 @@ public sealed record LedgerStockMapping( /// /// Turns ledger items into Auto Nest stock rows: each bundle or full sheet becomes a row, and -/// drops of the same usable size share a row. The engine sees only the ledger:{n} ids. +/// drops of the same usable size and spec share a row. Rows run in rank order and keep the +/// ledger's order within a rank. The engine sees only the ledger:{n} ids, never ranks. /// public static class LedgerStockMapper { @@ -68,21 +84,28 @@ public static class LedgerStockMapper public static double ThicknessInches(double thickness, Units units) => System.Math.Round(units == Units.Millimeters ? thickness / MillimetersPerInch : thickness, 4); - /// Ledger items in ledger order. + /// Ledger items in ledger order (rank, then location code, then item code). /// The nest's units; ledger sizes are always inches. /// /// Bounding sizes of the parts to nest, in nest units. An item no part fits in either /// orientation is skipped; null or empty keeps every size. /// + /// The response's specs, which name each row's grade and finish; null leaves rows unlabeled. public static LedgerStockMapping Map(IEnumerable items, Units units, - IReadOnlyCollection? partEnvelopes = null) + IReadOnlyCollection? partEnvelopes = null, IEnumerable? specs = null) { ArgumentNullException.ThrowIfNull(items); var scale = units == Units.Millimeters ? MillimetersPerInch : 1.0; var unknown = 0; var tooSmall = 0; + var specById = new Dictionary(); + foreach (var spec in specs ?? Array.Empty()) + { + if (spec != null) + specById.TryAdd(spec.MaterialSpecId, spec); + } var entries = new List(); - var drops = new Dictionary<(decimal Width, decimal Length, bool Irregular), Entry>(); + var drops = new Dictionary<(long Spec, decimal Width, decimal Length, bool Irregular), Entry>(); foreach (var item in items) { @@ -110,13 +133,16 @@ public static class LedgerStockMapper } var irregular = IsShape(item, LedgerItemShapes.IrregularConservativeRectangle); + specById.TryGetValue(item.MaterialSpecId, out var itemSpec); + var rank = item.Rank ?? itemSpec?.Rank ?? 1; if (IsKind(item, LedgerItemKinds.Drop)) { - // ML stores sizes as decimal(10,3), so identical drops match after rounding. - var key = (System.Math.Round(shortIn, 3), System.Math.Round(longIn, 3), irregular); + // ML stores sizes as decimal(10,3), so identical drops match after rounding. Drops + // of different specs stay apart: they differ in grade, finish or rank. + var key = (item.MaterialSpecId, System.Math.Round(shortIn, 3), System.Math.Round(longIn, 3), irregular); if (!drops.TryGetValue(key, out var group)) { - group = new Entry(size, IsDrop: true, irregular); + group = new Entry(size, IsDrop: true, irregular, rank, Label(itemSpec)); drops.Add(key, group); entries.Add(group); } @@ -124,19 +150,17 @@ public static class LedgerStockMapper } else { - var sheet = new Entry(size, IsDrop: false, irregular); + var sheet = new Entry(size, IsDrop: false, irregular, rank, Label(itemSpec)); sheet.Items.Add(item); entries.Add(sheet); } } var rows = new List(entries.Count); - foreach (var entry in entries) + // ML already sends rank order; the stable sort keeps ledger order within a rank. + foreach (var entry in entries.OrderBy(e => e.Rank)) { - var ordered = entry.IsDrop - ? entry.Items.OrderBy(i => i.LocationCode, StringComparer.Ordinal) - .ThenBy(i => i.ItemCode, StringComparer.Ordinal).ToList() - : entry.Items; + var ordered = entry.Items; var codes = ordered.SelectMany(i => Enumerable.Repeat(i.ItemCode, i.Quantity)).ToList(); var locations = ordered.Select(i => i.LocationCode).Where(code => !string.IsNullOrEmpty(code)) .Distinct(StringComparer.Ordinal).ToList(); @@ -150,6 +174,8 @@ public static class LedgerStockMapper entry.IsDrop, entry.IsIrregular, codes, locations) { ItemLocations = itemLocations, + Rank = entry.Rank, + Label = entry.Label, }); } return new LedgerStockMapping(rows, unknown, tooSmall); @@ -169,13 +195,20 @@ public static class LedgerStockMapper return false; } + /// "A1011 P&O" from the spec's grade and finish; null when it has neither. + private static string? Label(LedgerStockSpec? spec) + { + var parts = new[] { spec?.Grade?.Trim(), spec?.Finish?.Trim() }.Where(p => !string.IsNullOrEmpty(p)).ToList(); + return parts.Count == 0 ? null : string.Join(" ", parts); + } + private static bool IsKind(LedgerStockItem item, string kind) => string.Equals(item.Kind, kind, StringComparison.OrdinalIgnoreCase); private static bool IsShape(LedgerStockItem item, string shape) => string.Equals(item.Shape, shape, StringComparison.OrdinalIgnoreCase); - private sealed record Entry(Size Size, bool IsDrop, bool IsIrregular) + private sealed record Entry(Size Size, bool IsDrop, bool IsIrregular, int Rank, string? Label) { public List Items { get; } = new(); } diff --git a/OpenNest.Tests/Data/LedgerStockMapperTests.cs b/OpenNest.Tests/Data/LedgerStockMapperTests.cs index 7e93bc12..406171d1 100644 --- a/OpenNest.Tests/Data/LedgerStockMapperTests.cs +++ b/OpenNest.Tests/Data/LedgerStockMapperTests.cs @@ -27,13 +27,13 @@ public class LedgerStockMapperTests Assert.Equal(new Size(48, 96), bundle.Size); Assert.Equal(1, mapping.Rows[1].Quantity); - // Width is the short side; drops matching after ML's 3-place rounding group, ordered by location then code. + // Width is the short side; drops matching after ML's 3-place rounding group, in ledger order. var drops = mapping.Rows[2]; Assert.True(drops.IsDrop); Assert.Equal(new Size(24, 36), drops.Size); Assert.Equal(3, drops.Quantity); - Assert.Equal(new[] { "D-1", "D-3", "D-2" }, drops.ItemCodes); - Assert.Equal(new[] { "R1", "R2" }, drops.Locations); + Assert.Equal(new[] { "D-2", "D-1", "D-3" }, drops.ItemCodes); + Assert.Equal(new[] { "R2", "R1" }, drops.Locations); Assert.Equal(new[] { "D-9" }, mapping.Rows[3].ItemCodes); Assert.Equal(2, mapping.SheetRows); @@ -41,6 +41,79 @@ public class LedgerStockMapperTests Assert.Equal("Ledger: 2 sheet sizes, 4 drops", mapping.Summary); } + [Fact] + public void DropsOfOneSizeFromDifferentSpecsKeepTheirOwnRows() + { + var mapping = LedgerStockMapper.Map(new[] + { + Item("D-1", LedgerItemKinds.Drop, 24, 36, spec: 11, rank: 1), + Item("D-2", LedgerItemKinds.Drop, 24, 36, spec: 12, rank: 2), + Item("D-3", LedgerItemKinds.Drop, 24, 36, spec: 11, rank: 1, location: "R2"), + }, Units.Inches, specs: new[] { Spec(11, 1, "A1011", "HR"), Spec(12, 2, "A1011", "P&O") }); + + Assert.Equal(2, mapping.Rows.Count); + Assert.Equal(new[] { "D-1", "D-3" }, mapping.Rows[0].ItemCodes); + Assert.Equal("A1011 HR", mapping.Rows[0].Label); + Assert.False(mapping.Rows[0].IsSubstitute); + Assert.Equal(new[] { "D-2" }, mapping.Rows[1].ItemCodes); + Assert.Equal("A1011 P&O", mapping.Rows[1].Label); + Assert.Equal(2, mapping.Rows[1].Rank); + Assert.True(mapping.Rows[1].IsSubstitute); + Assert.Equal("Ledger: 0 sheet sizes, 3 drops, 1 substitute row", mapping.Summary); + } + + [Fact] + public void RowsRunInRankOrderAndKeepLedgerOrderWithinARank() + { + // Out of rank order on purpose; within a rank the input order is the ledger's own. + var mapping = LedgerStockMapper.Map(new[] + { + Item("P-2", LedgerItemKinds.FullSheet, 48, 96, spec: 12, rank: 2, location: "R1"), + Item("H-9", LedgerItemKinds.Drop, 24, 36, spec: 11, rank: 1, location: "A1"), + Item("H-1", LedgerItemKinds.Drop, 24, 36, spec: 11, rank: 1, location: "B2"), + Item("X-1", LedgerItemKinds.FullSheet, 60, 120, spec: 10, rank: 0, location: "Z9"), + Item("H-5", LedgerItemKinds.FullSheet, 48, 96, spec: 11, rank: 1, location: "C3"), + Item("L-1", LedgerItemKinds.FullSheet, 48, 96, spec: 13, rank: 3, location: "A1"), + }, Units.Inches, specs: new[] + { + Spec(10, 0, "A36", "HR"), Spec(11, 1, "A1011", "HR"), Spec(12, 2, "A1011", "P&O"), Spec(13, 3, "A1011", "Laser"), + }); + + Assert.Equal(new[] { 0, 1, 1, 2, 3 }, mapping.Rows.Select(r => r.Rank)); + Assert.Equal(new[] { "X-1", "H-9", "H-5", "P-2", "L-1" }, mapping.Rows.Select(r => r.ItemCodes[0])); + Assert.Equal(new[] { "H-9", "H-1" }, mapping.Rows[1].ItemCodes); + Assert.Equal(new[] { "A1", "B2" }, mapping.Rows[1].Locations); + Assert.Equal(new[] { "ledger:1", "ledger:2", "ledger:3", "ledger:4", "ledger:5" }, mapping.Rows.Select(r => r.Id)); + Assert.Equal(new[] { false, false, false, true, true }, mapping.Rows.Select(r => r.IsSubstitute)); + } + + [Fact] + public void AnOlderLedgerWithoutRanksOrGradesIsRankOneAndUnlabeled() + { + var mapping = LedgerStockMapper.Map(new[] + { + Item("S-1", LedgerItemKinds.FullSheet, 48, 96, spec: 7), + Item("D-1", LedgerItemKinds.Drop, 24, 36, spec: 7), + }, Units.Inches, specs: new[] { new LedgerStockSpec(7, "A36 1/4", 0.25m, null) }); + + Assert.All(mapping.Rows, row => + { + Assert.Equal(1, row.Rank); + Assert.Null(row.Label); + Assert.False(row.IsSubstitute); + }); + Assert.Equal(new[] { "S-1", "D-1" }, mapping.Rows.Select(r => r.ItemCodes[0])); + } + + [Fact] + public void ItemRankFallsBackToItsSpecsRank() + { + var row = Assert.Single(LedgerStockMapper.Map(new[] { Item("S-1", LedgerItemKinds.FullSheet, 48, 96, spec: 12) }, + Units.Inches, specs: new[] { Spec(12, 2, "A36", null) }).Rows); + Assert.Equal(2, row.Rank); + Assert.Equal("A36", row.Label); + } + [Fact] public void UnknownShapesAreSkippedAndCounted() { @@ -123,6 +196,12 @@ public class LedgerStockMapperTests } private static LedgerStockItem Item(string code, string kind, decimal width, decimal length, int quantity = 1, - string shape = LedgerItemShapes.Rectangular, string location = "R1") => - new(code, 7, kind, width, length, quantity, shape, location, location, null, null, DateTime.UtcNow); + string shape = LedgerItemShapes.Rectangular, string location = "R1", long spec = 7, int? rank = null) => + new(code, spec, kind, width, length, quantity, shape, location, location, null, null, DateTime.UtcNow) + { + Rank = rank, + }; + + private static LedgerStockSpec Spec(long id, int rank, string grade, string? finish) => + new(id, $"{grade} {finish}", 0.0598m, "16 ga") { Rank = rank, MaterialFamily = "Steel", Grade = grade, Finish = finish }; } diff --git a/OpenNest.Tests/Data/MaterialLedgerClientTests.cs b/OpenNest.Tests/Data/MaterialLedgerClientTests.cs index 6d93731d..dea26965 100644 --- a/OpenNest.Tests/Data/MaterialLedgerClientTests.cs +++ b/OpenNest.Tests/Data/MaterialLedgerClientTests.cs @@ -51,6 +51,48 @@ public class MaterialLedgerClientTests Assert.Null(item.CostPerUnit); } + [Fact] + public async Task RankedStockReadsRanksAndCatalogNames() + { + var client = Client(_ => Json(""" + { + "mapped": true, + "specs": [ + { "materialSpecId": 12, "displayName": "A1011 HR 16 ga", "thicknessIn": 0.0598, "gaugeLabel": "16 ga", + "rank": 1, "materialFamily": "Steel", "grade": "A1011", "finish": "HR" }, + { "materialSpecId": 13, "displayName": "A1011 P&O 16 ga", "thicknessIn": 0.0598, "gaugeLabel": "16 ga", + "rank": 2, "materialFamily": "Steel", "grade": "A1011", "finish": null } + ], + "items": [{ + "itemCode": "FS-4896", "materialSpecId": 13, "rank": 2, "kind": "FullSheet", "widthIn": 48, "lengthIn": 96, + "quantity": 1, "shape": "Rectangular", "locationCode": "R1-A", "locationName": "Rack 1 A", + "parentLocationCode": "R1", "costPerUnit": null, "lastObservedAtUtc": "2026-10-20T14:00:00" + }] + } + """)); + + var ok = Assert.IsType(await client.GetStockAsync("Steel", "HR", 0.0598, default)); + + Assert.Equal(new int?[] { 1, 2 }, ok.Specs.Select(s => s.Rank)); + Assert.Equal(("Steel", "A1011", "HR"), (ok.Specs[0].MaterialFamily, ok.Specs[0].Grade, ok.Specs[0].Finish)); + Assert.Null(ok.Specs[1].Finish); + Assert.Equal(2, Assert.Single(ok.Items).Rank); + } + + [Fact] + public async Task AnOlderLedgerWithoutRanksStillDeserializes() + { + var ok = Assert.IsType(await Client(_ => Json(MappedBody)) + .GetStockAsync("Steel", "A36", 0.25, default)); + + var spec = Assert.Single(ok.Specs); + Assert.Null(spec.Rank); + Assert.Null(spec.MaterialFamily); + Assert.Null(spec.Grade); + Assert.Null(spec.Finish); + Assert.Null(Assert.Single(ok.Items).Rank); + } + [Fact] public async Task UnmappedMaterialIsDistinctFromEmptyStock() { diff --git a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs index b54951b6..fc92e2ac 100644 --- a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs @@ -206,6 +206,50 @@ public class AutoNestFormLedgerStockTests Assert.Contains("R1, R2", tooltip); } + [Fact] + public void RankedRowsShowTheirGradeAndSubstitutesAreMarkedButTicked() => RunSta(() => + { + using var form = new LedgerTestForm(new Nest { Material = new Material("Steel", "HR"), Thickness = 0.0598 }); + var grid = GetGrid(form); + Assert.IsAssignableFrom(grid.DataSource).Clear(); + form.ApplyLedgerStock(new LedgerStockResult.Ok(new[] + { + Spec(11, 1, "A1011", "HR"), Spec(12, 2, "A1011", "P&O"), + }, new[] + { + Item("H-1", LedgerItemKinds.FullSheet, 48, 96, 1) with { MaterialSpecId = 11, Rank = 1 }, + Item("P-1", LedgerItemKinds.Drop, 24, 36, 1, "B3") with { MaterialSpecId = 12, Rank = 2 }, + })); + + var rows = Assert.IsAssignableFrom(grid.DataSource); + Assert.Equal(new[] { "A1011 HR", "A1011 P&O for HR" }, rows.Cast().Select(r => (string)GetValue(r, "Source"))); + Assert.All(rows.Cast(), r => Assert.True((bool)GetValue(r, "Use"))); + Assert.Equal(new[] { "ledger:1", "ledger:2" }, form.GetPlateOptions().Select(o => o.Id)); + Assert.Contains("1 substitute row", Label(form).Text); + + var source = grid.Columns.Cast().Single(c => c.DataPropertyName == "Source").Index; + Assert.Equal(System.Drawing.SystemColors.GrayText, FormattedForeColor(grid, source, 0)); + Assert.Equal(AutoNestForm.SubstituteColor, FormattedForeColor(grid, source, 1)); + + var tooltip = AutoNestForm.LedgerTooltip(form.LedgerRows[1], "HR"); + Assert.StartsWith("Substitute: MaterialLedger accepts A1011 P&O in place of HR (rank 2).", tooltip); + Assert.Contains("Items: P-1", tooltip); + Assert.Contains("Locations: B3", tooltip); + }); + + [Fact] + public void SourceTextFallsBackForUnlabeledAndIrregularRows() + { + var plain = new LedgerStockRow("ledger:1", new Size(24, 36), 1, true, false, new[] { "D-1" }, new[] { "R1" }); + Assert.Equal("Ledger", AutoNestForm.LedgerSource(plain, "HR")); + Assert.Equal("Ledger (irr.)", AutoNestForm.LedgerSource(plain with { IsIrregular = true }, "HR")); + Assert.Equal("A36 (substitute)", AutoNestForm.LedgerSource(plain with { Rank = 4, Label = "A36" }, "")); + Assert.Equal("A1011 P&O for HR (irr.)", + AutoNestForm.LedgerSource(plain with { Rank = 2, Label = "A1011 P&O", IsIrregular = true }, "HR")); + Assert.Equal("A36 HR", AutoNestForm.LedgerSource(plain with { Rank = 0, Label = "A36 HR" }, "HR")); + Assert.StartsWith("Items:", AutoNestForm.LedgerTooltip(plain, "HR")); + } + [Fact] public void LedgerRowsCarryTheItemCodesAutoNestAssigns() => RunSta(() => { @@ -275,9 +319,27 @@ public class AutoNestFormLedgerStockTests private static Button Button(AutoNestForm form) => Assert.IsType