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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 17:40:45 -04:00
1 parent f847b216f2
commit be01124a0f
7 files changed
+323 -44

No files matched your search

+18 -3
View File
@@ -9,12 +9,24 @@ public sealed record LedgerStockResponse(
IReadOnlyList<LedgerStockSpec>? Specs,
IReadOnlyList<LedgerStockItem>? Items);
public sealed record LedgerStockSpec(long MaterialSpecId, string DisplayName, decimal ThicknessIn, string? GaugeLabel);
/// <summary>
/// Mirrors ML <c>StockApi.OpenNestStockSpecDto</c>. <see cref="Rank"/> 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.
/// </summary>
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; }
}
/// <summary>
/// Mirrors ML <c>StockApi.StockItemDto</c>. Sizes are inches with <see cref="WidthIn"/> the short
/// side; <see cref="Quantity"/> is free after active holds. <see cref="Kind"/> and
/// <see cref="Shape"/> stay strings so a new ML enum value still deserializes.
/// <see cref="Shape"/> stay strings so a new ML enum value still deserializes. <see cref="Rank"/>
/// is its spec's rank, null from an older ledger.
/// </summary>
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; }
}
/// <summary>ML <c>InventoryItemKind</c> names.</summary>
public static class LedgerItemKinds
@@ -6,7 +6,7 @@ namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// One Auto Nest stock row built from ledger items. <see cref="Size"/> is in nest units with
/// Width the short side. <see cref="ItemCodes"/> lists one code per unit of
/// <see cref="Quantity"/> (a bundle repeats its code), in the order later steps assign them.
/// <see cref="Quantity"/> (a bundle repeats its code), in ledger order, which later steps assign.
/// </summary>
public sealed record LedgerStockRow(
string Id,
@@ -20,6 +20,18 @@ public sealed record LedgerStockRow(
/// <summary>Each item code's ledger location code, for display only.</summary>
public IReadOnlyDictionary<string, string> ItemLocations { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
/// <summary>
/// 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.
/// </summary>
public int Rank { get; init; } = 1;
/// <summary>The catalog grade and finish the row is cut from, e.g. "A1011 P&amp;O"; null when the ledger sends neither.</summary>
public string? Label { get; init; }
/// <summary>True for stock ML accepts in place of the preferred material (rank above 1).</summary>
public bool IsSubstitute => Rank > 1;
}
/// <summary>The rows offered to Auto Nest and the counts of ledger items left out.</summary>
@@ -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);
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops (2 skipped: unknown shape)".</summary>
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
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(
/// <summary>
/// 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 <c>ledger:{n}</c> 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 <c>ledger:{n}</c> ids, never ranks.
/// </summary>
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);
/// <param name="items">Ledger items in ledger order.</param>
/// <param name="items">Ledger items in ledger order (rank, then location code, then item code).</param>
/// <param name="units">The nest's units; ledger sizes are always inches.</param>
/// <param name="partEnvelopes">
/// 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.
/// </param>
/// <param name="specs">The response's specs, which name each row's grade and finish; null leaves rows unlabeled.</param>
public static LedgerStockMapping Map(IEnumerable<LedgerStockItem> items, Units units,
IReadOnlyCollection<Size>? partEnvelopes = null)
IReadOnlyCollection<Size>? partEnvelopes = null, IEnumerable<LedgerStockSpec>? specs = null)
{
ArgumentNullException.ThrowIfNull(items);
var scale = units == Units.Millimeters ? MillimetersPerInch : 1.0;
var unknown = 0;
var tooSmall = 0;
var specById = new Dictionary<long, LedgerStockSpec>();
foreach (var spec in specs ?? Array.Empty<LedgerStockSpec>())
{
if (spec != null)
specById.TryAdd(spec.MaterialSpecId, spec);
}
var entries = new List<Entry>();
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<LedgerStockRow>(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;
}
/// <summary>"A1011 P&amp;O" from the spec's grade and finish; null when it has neither.</summary>
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<LedgerStockItem> Items { get; } = new();
}
+84 -5
View File
@@ -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 };
}
@@ -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<LedgerStockResult.Ok>(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<LedgerStockResult.Ok>(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()
{
@@ -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<IList>(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<IList>(grid.DataSource);
Assert.Equal(new[] { "A1011 HR", "A1011 P&O for HR" }, rows.Cast<object>().Select(r => (string)GetValue(r, "Source")));
Assert.All(rows.Cast<object>(), 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<DataGridViewColumn>().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<Button>(form.Controls.Find("ledgerButton", true).Single());
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 };
private static void SetValue(object row, string property, object value) =>
TypeDescriptor.GetProperties(row)[property]!.SetValue(row, value);
private static object GetValue(object row, string property) =>
TypeDescriptor.GetProperties(row)[property]!.GetValue(row)!;
/// <summary>The fore colour the grid's CellFormatting handlers give a cell.</summary>
private static System.Drawing.Color FormattedForeColor(DataGridView grid, int column, int row)
{
var args = new DataGridViewCellFormattingEventArgs(column, row, grid[column, row].Value, typeof(string),
new DataGridViewCellStyle());
typeof(DataGridView).GetMethod("OnCellFormatting",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null,
new[] { typeof(DataGridViewCellFormattingEventArgs) }, null)!
.Invoke(grid, new object[] { args });
return args.CellStyle!.ForeColor;
}
private static void RunSta(Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
}
+45 -11
View File
@@ -27,6 +27,9 @@ namespace OpenNest.Forms
private bool loadingLedger;
private string ledgerSummary = "";
/// <summary>A dark amber that reads on the grid's white rows.</summary>
internal static readonly System.Drawing.Color SubstituteColor = System.Drawing.Color.FromArgb(160, 82, 0);
public AutoNestForm(Nest nest)
{
this.nest = nest;
@@ -205,8 +208,9 @@ namespace OpenNest.Forms
{
DataPropertyName = "Source",
HeaderText = "Source",
ToolTipText = "Where a ledger row's stock comes from. \"for {grade}\" marks a substitute MaterialLedger accepts in place of the nest's grade.",
ReadOnly = true,
Width = 90,
Width = 160,
AutoSizeMode = DataGridViewAutoSizeColumnMode.None,
}
);
@@ -601,7 +605,7 @@ namespace OpenNest.Forms
switch (result)
{
case LedgerStockResult.Ok ok:
var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes());
var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes(), ok.Specs);
ReplaceLedgerRows(mapping.Rows);
ledgerSummary = mapping.Rows.Count == 0 && mapping.SkippedUnknownShape + mapping.SkippedTooSmall == 0
? $"Ledger: no free stock of {DescribeMaterial()}."
@@ -659,6 +663,8 @@ namespace OpenNest.Forms
if (items[i].LedgerRow != null)
items.RemoveAt(i);
}
// Substitutes load ticked like the rest; the programmer unticks any not wanted.
var grade = nest.Material?.Grade?.Trim();
foreach (var row in rows)
{
items.Add(new PlateOptionItem
@@ -666,6 +672,8 @@ namespace OpenNest.Forms
Size = FormatSize(System.Math.Round(row.Size.Width, 3), System.Math.Round(row.Size.Length, 3)),
Quantity = row.Quantity,
LedgerRow = row,
Source = LedgerSource(row, grade),
Tooltip = LedgerTooltip(row, grade),
});
}
}
@@ -716,23 +724,48 @@ namespace OpenNest.Forms
private void PlateGrid_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e)
{
var property = plateGrid.Columns[e.ColumnIndex].DataPropertyName;
if (ItemAt(e.RowIndex)?.LedgerRow != null && property is "Size" or "Cost" or "Source")
var ledger = ItemAt(e.RowIndex)?.LedgerRow;
if (ledger == null)
return;
// A substitute's source stays in full colour so it stands out from the preferred stock.
if (property == "Source" && ledger.IsSubstitute)
e.CellStyle.ForeColor = SubstituteColor;
else if (property is "Size" or "Cost" or "Source")
e.CellStyle.ForeColor = System.Drawing.SystemColors.GrayText;
}
private void PlateGrid_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e)
{
var ledger = ItemAt(e.RowIndex)?.LedgerRow;
if (ledger != null)
e.ToolTipText = LedgerTooltip(ledger);
var item = ItemAt(e.RowIndex);
if (item?.LedgerRow != null)
e.ToolTipText = item.Tooltip;
}
internal static string LedgerTooltip(LedgerStockRow row)
/// <summary>
/// A ledger row's Source cell: its grade and finish ("A1011 P&amp;O", or "Ledger" when the
/// ledger names neither), "for HR" on a substitute, and "(irr.)" on an irregular drop.
/// </summary>
internal static string LedgerSource(LedgerStockRow row, string requestedGrade)
{
var text = row.Label ?? "Ledger";
if (row.IsSubstitute)
text += string.IsNullOrEmpty(requestedGrade) ? " (substitute)" : $" for {requestedGrade}";
return row.IsIrregular ? text + " (irr.)" : text;
}
internal static string LedgerTooltip(LedgerStockRow row, string requestedGrade = null)
{
const int Shown = 12;
var codes = row.ItemCodes.Distinct(StringComparer.Ordinal).ToList();
var codeText = string.Join(", ", codes.Take(Shown)) + (codes.Count > Shown ? $", +{codes.Count - Shown} more" : "");
var text = $"Items: {codeText}";
var text = "";
if (row.IsSubstitute)
text = $"Substitute: MaterialLedger accepts {row.Label ?? "this stock"} in place of "
+ $"{(string.IsNullOrEmpty(requestedGrade) ? "the nest's grade" : requestedGrade)} (rank {row.Rank}). "
+ "Untick Use to leave it out.\n";
else if (row.Label != null)
text = $"Source: {row.Label}\n";
text += $"Items: {codeText}";
if (row.Locations.Count > 0)
text += $"\nLocations: {string.Join(", ", row.Locations.Take(Shown))}"
+ (row.Locations.Count > Shown ? $", +{row.Locations.Count - Shown} more" : "");
@@ -892,12 +925,13 @@ namespace OpenNest.Forms
public double Cost { get; set; }
public string Source => LedgerRow == null ? ""
: LedgerRow.IsIrregular ? "Ledger (irr.)"
: "Ledger";
/// <summary>Blank for hand-typed rows; see <see cref="LedgerSource"/>.</summary>
public string Source { get; set; } = "";
/// <summary>Set for rows loaded from MaterialLedger; these are never saved with the nest.</summary>
internal LedgerStockRow LedgerRow { get; set; }
internal string Tooltip { get; set; }
}
}
}
+23 -9
View File
@@ -274,7 +274,14 @@ mapping names, so it reports the connection as working when ML answers `mapped:
`GET {url}/api/stock/opennest?material=&grade=&thicknessIn=` with the `X-Api-Key`
header and a 5 s timeout. The request sends the nest's `Material.Name`, its `Material.Grade`
and the thickness converted to inches (rounded to 4 places). ML resolves its own
OpenNest material mapping, and OpenNest keeps no copy of it. The button is enabled when
OpenNest material mapping, and OpenNest keeps no copy of it. OpenNest's grade is a shop
condition such as HR or P&O; ML's acceptance rule for the material and grade decides which
catalog grades and finishes may fill it, ranked by preference. Rank 0 is an exact mapping
override, rank 1 the rule's preferred entry, and higher ranks are substitutes, which only
ever upgrade (P&O may fill HR, never the reverse). Specs carry `rank`, `materialFamily`,
`grade` and `finish`; items carry `rank` and arrive in rank, location code, item code order.
An older ledger that sends none of these still loads: its stock counts as rank 1, unlabeled.
The button is enabled when
the ledger is configured and the nest has a material and a thickness. The request runs
while the dialog stays usable, and closing the dialog cancels it. The client
(`OpenNest.Data/MaterialLedger/`) returns `NotConfigured`, `Unreachable(reason)`,
@@ -287,23 +294,30 @@ with the short side first, and become `Size(short, long)` in nest units:
| Ledger item | Stock row |
|---|---|
| `FullSheetBundle` / `FullSheet`, free quantity N | one row, quantity N |
| `Drop` | drops of identical usable size (3-place inches) share one row; quantity is the count |
| `Drop` | drops of identical usable size (3-place inches) and the same spec share one row; quantity is the count |
| `IrregularConservativeRectangle` | its conservative rectangle, kept apart and marked "irregular" |
| `Unknown` or an unrecognized shape | skipped and counted in the summary |
| no part to nest fits in either orientation | skipped and counted in the summary |
Each row keeps its item codes (one per sheet, drops ordered by location then code)
and locations for the row tooltip and for later item assignment. The engine sees only
ordinary `NestPlateStock` rows with ids `ledger:{n}` and finite quantities. It never
sees item codes, and `OpenNest.Engine` has no ledger reference.
Rows run in rank order. Within a rank, and within each row's item codes (one per sheet),
the ledger's own order is kept, so item assignment takes the preferred, first-listed items
first. Each row also keeps its locations for the tooltip, its rank and a label from its
spec's grade and finish (for example "A1011 P&O"). The engine sees only ordinary
`NestPlateStock` rows with ids `ledger:{n}` and finite quantities. It never sees item
codes or ranks, so it does not prefer low-rank stock; a stock priority would be a
separate `NestJob` input. `OpenNest.Engine` has no ledger reference.
**Grid.** A load replaces earlier ledger rows and keeps hand-typed rows. Ledger rows
show Source "Ledger", and their size and cost are read-only. You can lower a ledger
**Grid.** A load replaces earlier ledger rows and keeps hand-typed rows. A ledger row's
Source shows its label, or "Ledger" when the ledger sends no grade or finish, with
"(irr.)" for an irregular drop. A substitute (rank above 1) reads, for example,
"A1011 P&O for HR" in amber, and its tooltip names the substitution and rank ahead of the
usual item codes and locations. Substitutes load ticked like other rows; untick **Use** to
keep them out of the run. Ledger rows' size and cost are read-only. You can lower a ledger
row's quantity (not above the free count) or untick **Use** to leave any row out.
The **Qty** column applies to every row: blank offers the size without limit, as
hand-typed rows always have, and a number is passed as `PlateOption.Quantity` to
`NestPlateStock.Quantity`. A summary line under the grid reports the load, for example
"Ledger: 3 sheet sizes, 7 drops (2 skipped: 2 unknown shape)", or the failure. An
"Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)", or the failure. An
unmapped material clears the ledger rows. A failed request keeps them.
**Persistence.** Ledger rows and quantities are request-local and are never written to