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