From 15750949dde66bcab57b5ea69a832f6d2f335783 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 25 Oct 2026 14:58:49 -0400 Subject: [PATCH] feat(nest): plates name the ledger item each sheet is cut from Auto Nest on MaterialLedger stock now records which inventory item each physical sheet uses, so ML can hold exactly those items instead of matching by size. - Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded). Core invalidates it: a resize clears it (turning the sheet keeps it), a lower Quantity truncates it, a nest material/grade/thickness change clears every plate's, ClearAll clears it and new plates never copy it. - Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment deals each ledger row's codes to its plates in Nest.Plates order (bundles repeat their code; clamp-removal variants use their base row). Rejected, discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock pairs committed plates with their solved stock; the engine stays ledger-free. - Plate list tooltip: "Stock: CODE (location)"; locations are display-only. - nest.json: optional plate stockItemCodes, omitted when empty, read leniently. - /api/nests/{id}/sheets: new plates[] { index, width, length, quantity, stockItemCodes }; sheets[] unchanged. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Nest.cs | 46 ++++- OpenNest.Core/Plate.cs | 43 ++++- OpenNest.Core/PlateManager.cs | 1 + .../MaterialLedger/LedgerStockAssignment.cs | 84 +++++++++ .../MaterialLedger/LedgerStockMapper.cs | 18 +- OpenNest.Data/NestSheets.cs | 25 +++ OpenNest.Engine/Jobs/NestPipelineCommit.cs | 18 ++ .../StockItemCodesPersistenceTests.cs | 79 ++++++++ OpenNest.IO/NestFormat.cs | 9 + OpenNest.IO/NestReader.cs | 4 + OpenNest.IO/NestWriter.cs | 1 + OpenNest.Server.Tests/NestSheetsTests.cs | 72 ++++++++ OpenNest.Server/NestArchive.cs | 53 +++++- .../Data/LedgerStockAssignmentTests.cs | 169 ++++++++++++++++++ OpenNest.Tests/Engine/NestRenestTests.cs | 14 ++ OpenNest.Tests/PlateStockItemCodesTests.cs | 136 ++++++++++++++ .../Forms/AutoNestFormLedgerStockTests.cs | 16 ++ .../Forms/EditNestPlateStockTooltipTests.cs | 33 ++++ OpenNest/Forms/AutoNestForm.cs | 6 + OpenNest/Forms/Commands/AutoNestCommand.cs | 18 ++ OpenNest/Forms/EditNestForm.cs | 11 +- docs/automatic-nesting.md | 11 ++ docs/nest-file-format.md | 17 ++ docs/nest-storage.md | 11 +- 24 files changed, 886 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs create mode 100644 OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs create mode 100644 OpenNest.Tests/Data/LedgerStockAssignmentTests.cs create mode 100644 OpenNest.Tests/PlateStockItemCodesTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs index 0d3c9063..47b2c906 100644 --- a/OpenNest.Core/Nest.cs +++ b/OpenNest.Core/Nest.cs @@ -51,9 +51,51 @@ namespace OpenNest public string MadeBy { get; set; } = ""; - public double Thickness { get; set; } + /// The stock thickness. A different value clears every plate's . + public double Thickness + { + get { return thickness; } + set + { + if (value != thickness) + ClearStockItemCodes(); + thickness = value; + } + } - public Material Material { get; set; } + private double thickness; + + /// + /// The stock material. A different name or grade (ignoring case and spacing) clears every + /// plate's : the coded items are another material. + /// + public Material Material + { + get { return material; } + set + { + if (!SameStock(material, value)) + ClearStockItemCodes(); + material = value; + } + } + + private Material material; + + private static bool SameStock(Material a, Material b) => + NameKey(a?.Name) == NameKey(b?.Name) && NameKey(a?.Grade) == NameKey(b?.Grade); + + private static string NameKey(string name) => + name == null ? "" : string.Join(' ', name.Split((char[])null, StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant(); + + private void ClearStockItemCodes() + { + // Plates is a public field a caller may have replaced. + if (Plates == null) + return; + foreach (var plate in Plates) + plate.StockItemCodes = Array.Empty(); + } public Units Units { get; set; } diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 8eca5685..ba93ec56 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -95,6 +95,9 @@ namespace OpenNest get { return size; } set { + // A turned sheet is the same sheet; any other size is no longer the coded item. + if (stockItemCodes.Count > 0 && !SameSheet(size, value)) + stockItemCodes = Array.Empty(); size = value; ResolveClampRail(); } @@ -102,6 +105,30 @@ namespace OpenNest private Size size; + private static bool SameSheet(Size a, Size b) => + a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width; + + /// + /// The inventory item each physical sheet of this plate is cut from: entry i names sheet + /// i of , and sheets past the end are uncoded. Empty when no sheet + /// is coded. A resize (other than turning the sheet) clears it, lowering + /// truncates it, and raising it leaves the extra sheets uncoded. + /// Display and hand-off data only: quantity accounting never reads it. + /// + public IReadOnlyList StockItemCodes + { + get { return stockItemCodes; } + set + { + ArgumentNullException.ThrowIfNull(value); + if (value.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Stock item codes must not be blank.", nameof(value)); + stockItemCodes = value.Take(System.Math.Max(quantity, 0)).ToArray(); + } + } + + private IReadOnlyList stockItemCodes = Array.Empty(); + public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; } /// @@ -457,9 +484,21 @@ namespace OpenNest } /// - /// The number of times to cut the plate. + /// The number of times to cut the plate. Lowering it drops the + /// of the sheets no longer cut. /// - public int Quantity { get; set; } + public int Quantity + { + get { return quantity; } + set + { + quantity = value; + if (stockItemCodes.Count > System.Math.Max(value, 0)) + stockItemCodes = stockItemCodes.Take(System.Math.Max(value, 0)).ToArray(); + } + } + + private int quantity; /// /// The quadrant the plate is located in. diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs index 17d0ebae..92bca444 100644 --- a/OpenNest.Core/PlateManager.cs +++ b/OpenNest.Core/PlateManager.cs @@ -236,6 +236,7 @@ namespace OpenNest remaining.CutOffs.Clear(); remaining.Parts.Clear(); remaining.Quantity = 1; + remaining.StockItemCodes = Array.Empty(); } finally { diff --git a/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs b/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs new file mode 100644 index 00000000..d87fad03 --- /dev/null +++ b/OpenNest.Data/MaterialLedger/LedgerStockAssignment.cs @@ -0,0 +1,84 @@ +namespace OpenNest.Data.MaterialLedger; + +/// +/// Gives the plates Auto Nest committed on ledger stock their item codes. Runs only after the +/// proposal was validated, kept and committed, so a rejected or cancelled one never changes +/// codes. Codes are hand-off data for MaterialLedger holds, not inventory accounting. +/// +public static class LedgerStockAssignment +{ + // ClampRemovalRecovery solves a sheet with clamps removed as "{stock id}::removed:{ids}". + private const string VariantSeparator = "::"; + + /// The nest's plates; codes are dealt out in this order. + /// Each plate the commit attached, with the engine stock id it was solved on. + /// The ledger rows offered to the run. + /// + /// Each row's codes are a queue: a plate takes the next codes of + /// its row (a bundle repeats its one code). A plate whose row runs out keeps the rest of its + /// sheets uncoded. A committed plate on hand-typed stock has no codes. + /// + public static void Assign(IList nestPlates, IEnumerable<(Plate Plate, string StockId)> committed, + IEnumerable rows) + { + ArgumentNullException.ThrowIfNull(nestPlates); + ArgumentNullException.ThrowIfNull(committed); + ArgumentNullException.ThrowIfNull(rows); + var queues = new Dictionary>(StringComparer.Ordinal); + foreach (var row in rows) + queues.TryAdd(row.Id, new Queue(row.ItemCodes)); + + foreach (var (plate, stockId) in committed.OrderBy(c => IndexOf(nestPlates, c.Plate))) + { + var codes = new List(); + if (stockId != null && queues.TryGetValue(BaseStockId(stockId), out var queue)) + { + while (codes.Count < plate.Quantity && queue.Count > 0) + codes.Add(queue.Dequeue()); + } + plate.StockItemCodes = codes; + } + } + + /// The offered stock id a solved sheet came from, without a clamp-removal suffix. + public static string BaseStockId(string stockId) + { + ArgumentNullException.ThrowIfNull(stockId); + var separator = stockId.IndexOf(VariantSeparator, StringComparison.Ordinal); + return separator < 0 ? stockId : stockId[..separator]; + } + + /// + /// "Stock: DROP-1182 (B3)" for display, with runs of one code counted ("SHEET-456 (A1) x3") + /// and uncoded sheets noted; null when no sheet is coded. Locations are display-only. + /// + public static string? Describe(Plate plate, IReadOnlyDictionary? locations) + { + ArgumentNullException.ThrowIfNull(plate); + var codes = plate.StockItemCodes; + if (codes.Count == 0) + return null; + var parts = new List(); + for (var i = 0; i < codes.Count;) + { + var run = 1; + while (i + run < codes.Count && codes[i + run] == codes[i]) + run++; + var text = locations != null && locations.TryGetValue(codes[i], out var location) + ? $"{codes[i]} ({location})" + : codes[i]; + parts.Add(run > 1 ? $"{text} x{run}" : text); + i += run; + } + var uncoded = plate.Quantity - codes.Count; + if (uncoded > 0) + parts.Add($"{uncoded} uncoded"); + return "Stock: " + string.Join(", ", parts); + } + + private static int IndexOf(IList plates, Plate plate) + { + var index = plates.IndexOf(plate); + return index < 0 ? int.MaxValue : index; + } +} diff --git a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs index 69e79e7d..a8eeb983 100644 --- a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs +++ b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs @@ -15,7 +15,12 @@ public sealed record LedgerStockRow( bool IsDrop, bool IsIrregular, IReadOnlyList ItemCodes, - IReadOnlyList Locations); + IReadOnlyList Locations) +{ + /// Each item code's ledger location code, for display only. + public IReadOnlyDictionary ItemLocations { get; init; } = + new Dictionary(StringComparer.Ordinal); +} /// The rows offered to Auto Nest and the counts of ledger items left out. public sealed record LedgerStockMapping( @@ -135,8 +140,17 @@ public static class LedgerStockMapper 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(); + var itemLocations = new Dictionary(StringComparer.Ordinal); + foreach (var item in ordered) + { + if (!string.IsNullOrEmpty(item.LocationCode)) + itemLocations.TryAdd(item.ItemCode, item.LocationCode); + } rows.Add(new LedgerStockRow($"{IdPrefix}{rows.Count + 1}", entry.Size, codes.Count, - entry.IsDrop, entry.IsIrregular, codes, locations)); + entry.IsDrop, entry.IsIrregular, codes, locations) + { + ItemLocations = itemLocations, + }); } return new LedgerStockMapping(rows, unknown, tooSmall); } diff --git a/OpenNest.Data/NestSheets.cs b/OpenNest.Data/NestSheets.cs index 0987df86..62cab5ab 100644 --- a/OpenNest.Data/NestSheets.cs +++ b/OpenNest.Data/NestSheets.cs @@ -15,6 +15,31 @@ public sealed class NestSheets /// One entry per drawing, including drawings with nothing nested, ordered by name. public IReadOnlyList Parts { get; init; } = Array.Empty(); + + /// One entry per plate in nest order, the same plates groups. + public IReadOnlyList Plates { get; init; } = Array.Empty(); +} + +/// +/// One plate of the nest: its size as stored with the shorter side, how many +/// times it is cut, and the inventory item each of those sheets is cut from. +/// +public sealed class NestPlateSheet +{ + /// The plate's 0-based position in the nest. + public int Index { get; init; } + + public double Width { get; init; } + + public double Length { get; init; } + + public int Quantity { get; init; } + + /// + /// Entry i is the inventory item code of sheet i; sheets past the end are uncoded, and a + /// bundle used for several sheets repeats its code. Empty when no sheet is coded. + /// + public IReadOnlyList StockItemCodes { get; init; } = Array.Empty(); } /// A sheet size as stored, with the shorter side. diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index e9be6eb9..3cbfcab1 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -49,6 +49,24 @@ public static class NestPipelineCommit return Attach(result, nextPlate); } + /// + /// Pairs the plates or returned + /// for with the stock each was solved on: one per proposed plate + /// with parts, in proposal order. + /// + public static IReadOnlyList<(Plate Plate, NestPlateStock Stock)> PairWithStock( + NestPipelineResult result, + IReadOnlyList applied + ) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(applied); + var solved = result.Plates.Where(p => p.Parts.Count > 0).ToList(); + if (solved.Count != applied.Count) + throw new ArgumentException("The plates were not committed from this result.", nameof(applied)); + return applied.Zip(solved, (plate, proposed) => (plate, proposed.Stock)).ToList(); + } + private static void EnsureCommittable(NestPipelineResult result, bool allowInvalid, CancellationToken token) { ArgumentNullException.ThrowIfNull(result); diff --git a/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs b/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs new file mode 100644 index 00000000..4ffc6a6f --- /dev/null +++ b/OpenNest.IO.Tests/StockItemCodesPersistenceTests.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Nodes; +using OpenNest.Geometry; +using OpenNest.Shapes; +using static OpenNest.IO.Tests.NestArchiveJson; + +namespace OpenNest.IO.Tests; + +/// +/// A plate's stock item codes survive the .nest round trip as an optional +/// stockItemCodes array; files without it load with no codes. +/// +public class StockItemCodesPersistenceTests +{ + private static Nest NestWithPlate(int quantity, params string[] codes) + { + var nest = new Nest("coded") { Material = new Material("Mild Steel", "A36"), Thickness = 0.25 }; + var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing(); + nest.Drawings.Add(drawing); + var plate = new Plate(30, 52.5) { Quantity = quantity }; + plate.Parts.Add(new Part(drawing, new Vector(1, 1))); + nest.Plates.Add(plate); + plate.StockItemCodes = codes; + return nest; + } + + private static byte[] Write(Nest nest) + { + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + return stream.ToArray(); + } + + [Fact] + public void CodesRoundTripInSheetOrder() + { + var (reopened, _) = RoundTrip(Write(NestWithPlate(3, "SHEET-456", "SHEET-456"))); + + var plate = Assert.Single(reopened.Plates); + Assert.Equal(3, plate.Quantity); + Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes); + Assert.Equal("Mild Steel", reopened.Material.Name); + } + + [Fact] + public void UncodedPlate_WritesNoKey() + { + var json = NestJson(Write(NestWithPlate(1))); + + Assert.False(json["plates"]![0]!.AsObject().ContainsKey("stockItemCodes")); + } + + [Fact] + public void OldFileWithoutTheKey_LoadsWithNoCodes() + { + var archive = Write(NestWithPlate(1, "DROP-1182")); + var json = NestJson(archive); + Assert.True(json["plates"]![0]!.AsObject().Remove("stockItemCodes")); + + var (reopened, _) = RoundTrip(ReplaceNestJson(archive, json)); + + Assert.Empty(Assert.Single(reopened.Plates).StockItemCodes); + } + + [Theory] + [InlineData("\"DROP-1\"", new string[0])] + [InlineData("[\"DROP-1\", 7, \"DROP-2\"]", new[] { "DROP-1", "DROP-2" })] + [InlineData("[\"DROP-1\", \"\", \"DROP-2\"]", new[] { "DROP-1" })] + [InlineData("[\"A\", \"B\", \"C\", \"D\"]", new[] { "A", "B", "C" })] + public void MalformedCodes_ReadLeniently(string value, string[] expected) + { + var archive = Write(NestWithPlate(3, "X")); + var json = NestJson(archive); + json["plates"]![0]!["stockItemCodes"] = JsonNode.Parse(value); + + var (reopened, _) = RoundTrip(ReplaceNestJson(archive, json)); + + Assert.Equal(expected, Assert.Single(reopened.Plates).StockItemCodes); + } +} diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 59446ffc..f8088d6f 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -649,6 +649,15 @@ namespace OpenNest.IO [JsonConverter(typeof(LenientStringListConverter))] public List RemovedClamps { get; init; } = new(); + /// + /// The inventory item code of each physical sheet, in sheet order; sheets past the + /// end are uncoded. Omitted when no sheet is coded; missing means none. Read + /// leniently: a wrongly shaped value reads as none, and non-string entries are skipped. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(LenientStringListConverter))] + public List StockItemCodes { get; init; } = new(); + /// /// The machine clamp rail this sheet follows, written only when it is not the /// nest's default rail (otherwise the sheet re-attaches to that one on load). diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 5d954e1e..8d5c66e0 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -639,6 +639,10 @@ namespace OpenNest.IO if (plateRail != null && plateRail.Matches(plate, removedClamps)) plate.SetClampRail(plateRail, removedClamps); + // Codes name sheets in order, so a blank entry ends the coded sheets. + if (p.StockItemCodes != null) + plate.StockItemCodes = p.StockItemCodes.TakeWhile(code => !string.IsNullOrWhiteSpace(code)).ToList(); + for (var partIndex = 0; partIndex < p.Parts.Count; partIndex++) { var partDto = p.Parts[partIndex]; diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 2d2ff045..af97b9d7 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -448,6 +448,7 @@ namespace OpenNest.IO ClampRail = Equals(plate.ClampRail, nest.PlateDefaults.ClampRail) ? null : ToDto(plate.ClampRail), + StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(), } ); } diff --git a/OpenNest.Server.Tests/NestSheetsTests.cs b/OpenNest.Server.Tests/NestSheetsTests.cs index 1d29f3be..4321edac 100644 --- a/OpenNest.Server.Tests/NestSheetsTests.cs +++ b/OpenNest.Server.Tests/NestSheetsTests.cs @@ -114,6 +114,75 @@ public sealed class NestSheetsTests Assert.Equal([("Kept", 2, 2)], Parts(sheets)); } + [Fact] + public async Task Plates_ListEachPlateWithItsStockItemCodes() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + var drawing = nest.Drawings.Single(); + nest.Plates.Single().StockItemCodes = ["DROP-1182"]; + // Stored rotated: width is still the short side. Two of three sheets are coded. + var bundle = new Plate(new Size(120, 60)) { Quantity = 3 }; + bundle.Parts.Add(new Part(drawing, new Vector(2, 3))); + bundle.StockItemCodes = ["SHEET-456", "SHEET-456"]; + nest.Plates.Add(bundle); + var uncoded = new Plate(new Size(48, 96)) { Quantity = 1 }; + uncoded.Parts.Add(new Part(drawing, new Vector(2, 3))); + nest.Plates.Add(uncoded); + nest.Plates.Add(new Plate(new Size(48, 96)) { Quantity = 1 }); + + var sheets = await Sheets(client, nest); + + Assert.Equal( + [ + (0, 20.0, 30.0, 1, "DROP-1182"), + (1, 60.0, 120.0, 3, "SHEET-456,SHEET-456"), + (2, 48.0, 96.0, 1, ""), + ], + Plates(sheets)); + Assert.Equal([(20.0, 30.0, 1), (48.0, 96.0, 1), (60.0, 120.0, 3)], Sizes(sheets)); + } + + [Fact] + public async Task Plates_ReadStockItemCodesAsNestReaderDoes() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var archive = TestNests.Zip("nest.json", """ + {"drawings":[{"id":1,"name":"Kept","quantity":{"required":2}}], + "plates":[{"id":1,"size":{"width":48,"length":96},"quantity":2,"parts":[{"drawingId":1}], + "stockItemCodes":["A",7,"B","C"]}, + {"id":2,"size":{"width":30,"length":40},"quantity":2,"parts":[{"drawingId":1}], + "stockItemCodes":["D","","E"]}, + {"id":3,"size":{"width":30,"length":40},"quantity":1,"parts":[{"drawingId":1}], + "stockItemCodes":{"bad":true}}]} + """); + var id = await Create(client, archive); + + var sheets = (await client.GetFromJsonAsync($"/api/nests/{id}/sheets", Options))!; + + Assert.Equal([(0, 48.0, 96.0, 2, "A,B"), (1, 30.0, 40.0, 2, "D"), (2, 30.0, 40.0, 1, "")], Plates(sheets)); + } + + [Fact] + public async Task Plates_SerializeAsTheDocumentedShape() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var nest = NestApiTests.SyntheticNest(); + nest.Plates.Single().StockItemCodes = ["DROP-1182"]; + var id = await Create(client, nest); + + using var document = JsonDocument.Parse(await client.GetStringAsync($"/api/nests/{id}/sheets")); + + var plate = Assert.Single(document.RootElement.GetProperty("plates").EnumerateArray()); + Assert.Equal( + ["index", "width", "length", "quantity", "stockItemCodes"], + plate.EnumerateObject().Select(p => p.Name).ToArray()); + Assert.Equal("DROP-1182", Assert.Single(plate.GetProperty("stockItemCodes").EnumerateArray()).GetString()); + } + [Fact] public async Task UnknownNest_Returns404() { @@ -128,6 +197,9 @@ public sealed class NestSheetsTests private static (double, double, int)[] Sizes(NestSheets sheets) => sheets.Sheets.Select(s => (s.Width, s.Length, s.Quantity)).ToArray(); + private static (int, double, double, int, string)[] Plates(NestSheets sheets) => + sheets.Plates.Select(p => (p.Index, p.Width, p.Length, p.Quantity, string.Join(",", p.StockItemCodes))).ToArray(); + private static (string, int, int)[] Parts(NestSheets sheets) => sheets.Parts.Select(p => (p.Name, p.Required, p.Nested)).ToArray(); diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs index a6639d8b..a7892897 100644 --- a/OpenNest.Server/NestArchive.cs +++ b/OpenNest.Server/NestArchive.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text.Json; +using System.Text.Json.Serialization; using OpenNest.Data; namespace OpenNest.Server; @@ -85,7 +86,33 @@ internal static class NestArchive .OrderBy(p => p.Name, StringComparer.Ordinal) .ToList(); - sheets = new NestSheets { NestId = record.Id, Revision = record.Revision, Sheets = sizes, Parts = parts }; + // NestReader: codes end at the first blank entry and at the plate's quantity. + var platesOut = plates + .Select((p, index) => + { + var size = p.Size ?? new SizeJson(); + return new NestPlateSheet + { + Index = index, + Width = System.Math.Min(size.Width, size.Length), + Length = System.Math.Max(size.Width, size.Length), + Quantity = p.Quantity, + StockItemCodes = (p.StockItemCodes ?? new()) + .TakeWhile(code => !string.IsNullOrWhiteSpace(code)) + .Take(System.Math.Max(p.Quantity, 0)) + .ToList(), + }; + }) + .ToList(); + + sheets = new NestSheets + { + NestId = record.Id, + Revision = record.Revision, + Sheets = sizes, + Parts = parts, + Plates = platesOut, + }; return null; } @@ -187,6 +214,30 @@ internal static class NestArchive public int Quantity { get; set; } = 1; public List? Parts { get; set; } public List? CutOffs { get; set; } + + [JsonConverter(typeof(LenientStringListJson))] + public List? StockItemCodes { get; set; } + } + + // NestReader's lenient list: a wrongly shaped value reads as empty, non-strings are skipped. + private sealed class LenientStringListJson : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var list = new List(); + if (document.RootElement.ValueKind != JsonValueKind.Array) + return list; + foreach (var element in document.RootElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + list.Add(element.GetString()!); + } + return list; + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value); } private sealed class SizeJson diff --git a/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs b/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs new file mode 100644 index 00000000..a9950794 --- /dev/null +++ b/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs @@ -0,0 +1,169 @@ +using OpenNest.Data.MaterialLedger; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Data; + +/// +/// After Auto Nest commits, each plate on ledger stock takes the next codes of its row in nest +/// order; nothing else touches codes, so a rejected proposal leaves them as they were. +/// +public class LedgerStockAssignmentTests +{ + private static LedgerStockRow Row(string id, double width, double length, params string[] codes) => + new(id, new Size(width, length), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false, + codes, Array.Empty()); + + [Fact] + public void Plates_DequeueTheirRowsCodes_InNestOrder_AndBundlesRepeatTheirCode() + { + var drops = Row("ledger:1", 30, 52.5, "DROP-1182", "DROP-1190", "DROP-1201"); + var bundle = Row("ledger:2", 60, 120, "SHEET-456", "SHEET-456", "SHEET-456"); + var nest = new Nest(); + var first = Add(nest, 1); + var second = Add(nest, 2); + var third = Add(nest, 2); + var hand = Add(nest, 1); + hand.StockItemCodes = ["STALE"]; + + // Committed out of nest order: codes still follow Nest.Plates. + LedgerStockAssignment.Assign(nest.Plates, + [(third, "ledger:2"), (first, "ledger:1"), (second, "ledger:1"), (hand, "option-0")], + [drops, bundle]); + + Assert.Equal(["DROP-1182"], first.StockItemCodes); + Assert.Equal(["DROP-1190", "DROP-1201"], second.StockItemCodes); + Assert.Equal(["SHEET-456", "SHEET-456"], third.StockItemCodes); + Assert.Empty(hand.StockItemCodes); + } + + [Fact] + public void ARowThatRunsOut_LeavesTheRemainingSheetsUncoded() + { + var nest = new Nest(); + var first = Add(nest, 2); + var second = Add(nest, 1); + + LedgerStockAssignment.Assign(nest.Plates, [(first, "ledger:1"), (second, "ledger:1")], + [Row("ledger:1", 30, 52.5, "DROP-1")]); + + Assert.Equal(["DROP-1"], first.StockItemCodes); + Assert.Empty(second.StockItemCodes); + } + + [Fact] + public void ASheetSolvedWithClampsRemoved_TakesItsBaseRowsCodes() + { + var nest = new Nest(); + var plate = Add(nest, 1); + + LedgerStockAssignment.Assign(nest.Plates, [(plate, "ledger:3::removed:clamp-2")], + [Row("ledger:3", 48, 96, "SHEET-9")]); + + Assert.Equal(["SHEET-9"], plate.StockItemCodes); + Assert.Equal("ledger:3", LedgerStockAssignment.BaseStockId("ledger:3::removed:clamp-2,clamp-3")); + } + + [Fact] + public void CommittedProposal_CodesThePlatesItWasSolvedOn() + { + var (nest, drawing, manager) = Workspace(); + using (manager) + { + var rows = new[] { Row("ledger:1", 30, 52.5, "DROP-1", "DROP-2") }; + var result = Solve(drawing, overlap: false); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + LedgerStockAssignment.Assign(nest.Plates, + NestPipelineCommit.PairWithStock(result, applied).Select(p => (p.Plate, p.Stock.Id)), rows); + + Assert.Equal(["DROP-1"], Assert.Single(applied).StockItemCodes); + } + } + + [Fact] + public void RejectedProposal_LeavesCodesUntouched() + { + var (nest, drawing, manager) = Workspace(); + using (manager) + { + var empty = nest.Plates.Single(); + empty.StockItemCodes = ["SHEET-OLD"]; + var result = Solve(drawing, overlap: true); + Assert.False(result.IsValid); + + // Auto Nest assigns only after a commit; without consent there is none. + Assert.Throws(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager)); + + Assert.Equal(["SHEET-OLD"], empty.StockItemCodes); + Assert.Empty(empty.Parts); + } + } + + [Fact] + public void Describe_NamesEachCodeWithItsLocation_CountsRuns_AndNotesUncodedSheets() + { + var plate = new Plate(60, 120) { Quantity = 4 }; + Assert.Null(LedgerStockAssignment.Describe(plate, null)); + + plate.StockItemCodes = ["SHEET-456", "SHEET-456", "DROP-1182"]; + var locations = new Dictionary { ["SHEET-456"] = "A1" }; + + Assert.Equal("Stock: SHEET-456 (A1) x2, DROP-1182, 1 uncoded", LedgerStockAssignment.Describe(plate, locations)); + } + + [Fact] + public void Mapper_KeepsEachItemsLocationForDisplay() + { + var mapping = LedgerStockMapper.Map( + [ + new LedgerStockItem("D-1", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular, + "R1", "Rack 1", null, null, DateTime.UtcNow), + new LedgerStockItem("D-2", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular, + "R2", "Rack 2", null, null, DateTime.UtcNow), + ], Units.Inches); + + var row = Assert.Single(mapping.Rows); + Assert.Equal("R1", row.ItemLocations["D-1"]); + Assert.Equal("R2", row.ItemLocations["D-2"]); + } + + private static Plate Add(Nest nest, int quantity) + { + var plate = new Plate(30, 52.5) { Quantity = quantity }; + nest.Plates.Add(plate); + return plate; + } + + private static (Nest Nest, Drawing Drawing, PlateManager Manager) Workspace() + { + var nest = new Nest(); + var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing(); + nest.Drawings.Add(drawing); + nest.CreatePlate(); + return (nest, drawing, new PlateManager(nest)); + } + + private static NestPipelineResult Solve(Drawing drawing, bool overlap) => + NestPipeline.Run(new OnFirstStock(overlap), "stub", new NestPipelineRequest("stub", + [new NestItem { Drawing = drawing, Quantity = 2 }], + [new NestPlateStock("ledger:1", new Size(30, 52.5), 2, 0.25, new Spacing(1, 1), 1)])); + + // Two copies on one sheet of the first stock row; overlapping when asked, so validation fails. + private sealed class OnFirstStock(bool overlap) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress progress = null, + CancellationToken token = default) => new( + NestJobStatus.Complete, NestJobStopReason.Completed, + [new NestJobPlateResult(0, job.Plates[0], + [ + new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0), + new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0), + ])], + [new PartFulfillment(job.Parts[0].Id, 2, 2, 0)], + [new StockUsage(job.Plates[0].Id, 1, 1)]); + } +} diff --git a/OpenNest.Tests/Engine/NestRenestTests.cs b/OpenNest.Tests/Engine/NestRenestTests.cs index 936b5325..2d9a5243 100644 --- a/OpenNest.Tests/Engine/NestRenestTests.cs +++ b/OpenNest.Tests/Engine/NestRenestTests.cs @@ -42,6 +42,20 @@ public class NestRenestTests Assert.StartsWith("Nested on 1 sheet, ", result.Note); } + [Fact] + public void Run_DropsTheOldPlatesStockItemCodes() + { + var nest = NestOf(("SYN-A", 4, 2, 2)); + var old = nest.CreatePlate(); + old.Parts.Add(new Part(nest.Drawings.First())); + old.StockItemCodes = ["DROP-1182"]; + + var result = NestRenest.Run(nest, "Rectangles"); + + Assert.True(result.Nested, result.Note); + Assert.All(nest.Plates, p => Assert.Empty(p.StockItemCodes)); + } + [Fact] public void Run_LeavesCopiesThatDoNotFit_AsRemainingQuantity_AndListsThem() { diff --git a/OpenNest.Tests/PlateStockItemCodesTests.cs b/OpenNest.Tests/PlateStockItemCodesTests.cs new file mode 100644 index 00000000..6edd6065 --- /dev/null +++ b/OpenNest.Tests/PlateStockItemCodesTests.cs @@ -0,0 +1,136 @@ +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests; + +/// +/// name the inventory item of each physical sheet, and Core +/// drops them as soon as they stop describing the sheet. +/// +public class PlateStockItemCodesTests +{ + private static Plate Coded(int quantity, params string[] codes) + { + var plate = new Plate(30, 52.5) { Quantity = quantity }; + plate.StockItemCodes = codes; + return plate; + } + + [Fact] + public void NewPlate_HasNoCodes() + { + Assert.Empty(new Plate().StockItemCodes); + Assert.Empty(new Nest().CreatePlate().StockItemCodes); + } + + [Fact] + public void Resize_ClearsCodes_ButTurningTheSheetKeepsThem() + { + var plate = Coded(1, "DROP-1182"); + + plate.Size = new Size(52.5, 30); + Assert.Equal(["DROP-1182"], plate.StockItemCodes); + plate.Rotate90(RotationType.CW); + Assert.Equal(["DROP-1182"], plate.StockItemCodes); + + plate.Size = new Size(30, 60); + Assert.Empty(plate.StockItemCodes); + } + + [Fact] + public void LowerQuantity_TruncatesCodes_AndHigherQuantityLeavesTheExtraSheetsUncoded() + { + var plate = Coded(3, "SHEET-456", "SHEET-456", "SHEET-789"); + + plate.Quantity = 2; + Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes); + + plate.Quantity = 5; + Assert.Equal(["SHEET-456", "SHEET-456"], plate.StockItemCodes); + + plate.Quantity = 0; + Assert.Empty(plate.StockItemCodes); + } + + [Fact] + public void Codes_BeyondQuantityAreDropped_AndBlankCodesAreRefused() + { + Assert.Equal(["A"], Coded(1, "A", "B").StockItemCodes); + Assert.Throws(() => Coded(2, "A", " ")); + Assert.Throws(() => new Plate().StockItemCodes = null); + } + + [Fact] + public void NestMaterialOrThicknessChange_ClearsEveryPlatesCodes() + { + var nest = new Nest { Material = new Material("Mild Steel", "A36"), Thickness = 0.25 }; + var first = Coded(1, "DROP-1"); + var second = Coded(2, "SHEET-2", "SHEET-2"); + nest.Plates.Add(first); + nest.Plates.Add(second); + + // Same material written differently, and the same thickness, are the same stock. + nest.Material = new Material(" mild steel ", "a36", 0.284); + nest.Thickness = 0.25; + Assert.Equal(["DROP-1"], first.StockItemCodes); + Assert.Equal(["SHEET-2", "SHEET-2"], second.StockItemCodes); + + nest.Material = new Material("Mild Steel", "A572"); + Assert.Empty(first.StockItemCodes); + Assert.Empty(second.StockItemCodes); + + first.StockItemCodes = ["DROP-1"]; + nest.Thickness = 0.1875; + Assert.Empty(first.StockItemCodes); + + first.StockItemCodes = ["DROP-1"]; + nest.Material = new Material("Stainless"); + Assert.Empty(first.StockItemCodes); + } + + [Fact] + public void PlateFromAnExistingPlate_CopiesNoCodes() + { + var nest = new Nest(); + var coded = Coded(1, "DROP-1182"); + nest.Plates.Add(coded); + + nest.PlateDefaults.SetFromExisting(coded); + var copy = nest.CreatePlate(); + + Assert.Equal(coded.Size, copy.Size); + Assert.Empty(copy.StockItemCodes); + } + + [Fact] + public void ClearAll_DropsTheRemainingPlatesCodes() + { + var nest = new Nest(); + var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing(); + nest.Drawings.Add(drawing); + var plate = Coded(2, "SHEET-1", "SHEET-1"); + plate.Parts.Add(new Part(drawing)); + nest.Plates.Add(plate); + using var manager = new PlateManager(nest); + + manager.ClearAll(); + + Assert.Empty(nest.Plates[0].StockItemCodes); + } + + [Fact] + public void Codes_AreNotQuantityAccounting() + { + var nest = new Nest(); + var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing(); + nest.Drawings.Add(drawing); + var plate = new Plate(48, 96) { Quantity = 2 }; + nest.Plates.Add(plate); + plate.Parts.Add(new Part(drawing)); + + plate.StockItemCodes = ["SHEET-1", "SHEET-1"]; + plate.StockItemCodes = []; + + Assert.Equal(2, drawing.Quantity.Nested); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs index 67022fed..b54951b6 100644 --- a/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs +++ b/OpenNest.WinForms.Tests/Forms/AutoNestFormLedgerStockTests.cs @@ -206,6 +206,22 @@ public class AutoNestFormLedgerStockTests Assert.Contains("R1, R2", tooltip); } + [Fact] + public void LedgerRowsCarryTheItemCodesAutoNestAssigns() => RunSta(() => + { + using var form = new LedgerTestForm(); + form.LoadPlateOptions(Manual(0, 0), 0.5); + GetGrid(form); + Assert.Empty(form.LedgerRows); + + form.ApplyLedgerStock(Stock); + + Assert.Equal(new[] { "ledger:1", "ledger:2" }, form.LedgerRows.Select(r => r.Id)); + Assert.Equal(new[] { "B-1", "B-1", "B-1" }, form.LedgerRows[0].ItemCodes); + Assert.Equal(new[] { "D-1", "D-2" }, form.LedgerRows[1].ItemCodes); + Assert.Equal("R1", form.LedgerRows[1].ItemLocations["D-2"]); + }); + private sealed class LedgerTestForm : AutoNestForm { public LedgerTestForm() : this(LedgerNest()) { } diff --git a/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs new file mode 100644 index 00000000..c0afdd67 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/EditNestPlateStockTooltipTests.cs @@ -0,0 +1,33 @@ +using System.Windows.Forms; +using OpenNest.Forms; +using OpenNest.Shapes; + +namespace OpenNest.WinForms.Tests.Forms; + +/// The plate list names each coded plate's inventory item, with its ledger location when known. +public class EditNestPlateStockTooltipTests +{ + [Fact] + public void CodedPlateShowsItsStockInThePlateListTooltip() => RunSta(() => + { + var nest = new Nest("stock tooltip"); + var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing(); + nest.Drawings.Add(drawing); + var plate = nest.CreatePlate(); + plate.Parts.Add(new Part(drawing)); + plate.StockItemCodes = ["DROP-1182"]; + using var form = new EditNestForm(nest); + form.PlateView.SetOverlapAutoCheck(null); + + form.LedgerLocations["DROP-1182"] = "B3"; + form.UpdatePlateList(); + + var plates = Assert.IsType(Assert.Single(form.Controls.Find("platesListView", true))); + Assert.True(plates.ShowItemToolTips); + Assert.Equal("Stock: DROP-1182 (B3)", plates.Items[0].ToolTipText); + Assert.Equal("", plates.Items[^1].ToolTipText); + }); + + private static void RunSta(System.Action testBody) => + StaTestThread.Run(testBody, TimeSpan.FromSeconds(60), "The stock tooltip STA test did not complete."); +} diff --git a/OpenNest/Forms/AutoNestForm.cs b/OpenNest/Forms/AutoNestForm.cs index 6c0e2202..5a6dab4a 100644 --- a/OpenNest/Forms/AutoNestForm.cs +++ b/OpenNest/Forms/AutoNestForm.cs @@ -274,6 +274,12 @@ namespace OpenNest.Forms return options; } + /// The ledger rows in the grid, ticked or not; their ids name the run's ledger stock. + internal IReadOnlyList LedgerRows => + plateGrid.DataSource is BindingList items + ? items.Where(i => i.LedgerRow != null).Select(i => i.LedgerRow).ToList() + : Array.Empty(); + public bool TryGetPlateOptions(out List options, out string error) => TryGetPlateOptions(out options, out _, out error); diff --git a/OpenNest/Forms/Commands/AutoNestCommand.cs b/OpenNest/Forms/Commands/AutoNestCommand.cs index a69493ef..7d772acf 100644 --- a/OpenNest/Forms/Commands/AutoNestCommand.cs +++ b/OpenNest/Forms/Commands/AutoNestCommand.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; +using OpenNest.Data.MaterialLedger; using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.Jobs; @@ -29,6 +30,7 @@ namespace OpenNest.Forms.Commands private readonly double salvageRate; private readonly double minRemnantSize; private List savedOptions; + private IReadOnlyList ledgerRows = Array.Empty(); private AutoNestCommand(IWin32Window owner, EditNestForm target, List items, List plateOptions, string engineName, double salvageRate, double minRemnantSize) @@ -73,6 +75,7 @@ namespace OpenNest.Forms.Commands form.EngineName ?? EngineSelection.EngineName, form.SalvageRate, form.MinRemnantSize) { savedOptions = savedOptions, + ledgerRows = form.OptimizePlateSize ? form.LedgerRows : Array.Empty(), }; } @@ -113,6 +116,7 @@ namespace OpenNest.Forms.Commands var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager, allowInvalid, cts.Token); + AssignLedgerStock(result, applied); if (applied.Count > 0) target.PlateManager.LoadAt(target.Nest.Plates.IndexOf(applied[0])); if (plateOptions?.Count > 0) @@ -192,6 +196,20 @@ namespace OpenNest.Forms.Commands return await solve; } + // Only a committed proposal gets codes: every return above leaves them as they were. + private void AssignLedgerStock(NestPipelineResult result, IReadOnlyList applied) + { + LedgerStockAssignment.Assign(target.Nest.Plates, + NestPipelineCommit.PairWithStock(result, applied).Select(p => (p.Plate, p.Stock.Id)), + ledgerRows); + foreach (var row in ledgerRows) + { + foreach (var (code, location) in row.ItemLocations) + target.LedgerLocations[code] = location; + } + target.UpdatePlateList(); + } + // Costs and unplaced parts were shown in the review; clamp removals exist only once kept. private void ShowResultNotices(IReadOnlyList applied) { diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 39ed9bf4..cd41a162 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -13,6 +13,7 @@ using OpenNest.CNC.CuttingStrategy; using OpenNest.Collections; using OpenNest.Controls; using OpenNest.Data; +using OpenNest.Data.MaterialLedger; using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.Sequencing; @@ -89,6 +90,7 @@ namespace OpenNest.Forms toolStrip1.Renderer = renderer; toolStrip2.Renderer = renderer; + platesListView.ShowItemToolTips = true; platesListView.SelectedIndexChanged += (sender, e) => { if (updatingPlateList || platesListView.SelectedIndices.Count == 0) @@ -290,6 +292,12 @@ namespace OpenNest.Forms } internal System.Action CuttingBeforePlan { get; set; } + /// + /// Ledger location of each item code Auto Nest loaded in this window, for the plate list's + /// stock tooltip. Display-only: only the codes are saved with the nest. + /// + internal Dictionary LedgerLocations { get; } = new(StringComparer.Ordinal); + internal void OpenValidationInspection() { if (CuttingInspection != null) @@ -1468,9 +1476,10 @@ namespace OpenNest.Forms #endregion - private static ListViewItem GetListViewItem(Plate plate, int id) + private ListViewItem GetListViewItem(Plate plate, int id) { var item = new ListViewItem(); + item.ToolTipText = LedgerStockAssignment.Describe(plate, LedgerLocations) ?? ""; item.Text = id.ToString(); item.SubItems.Add(plate.Size.ToString()); item.SubItems.Add(plate.Quantity.ToString()); diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index e33b4e01..c6fec3bc 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -310,6 +310,17 @@ unmapped material clears the ledger rows. A failed request keeps them. `Nest.PlateOptions`. After a kept result, the nest saves the hand-typed rows with their typed costs, as before. +**Item codes.** After a kept result is committed, `LedgerStockAssignment` gives each +plate on a ledger row that row's next item codes, in nest plate order, one per sheet of +the plate's quantity (a bundle repeats its code). It reads each sheet's stock from +`NestPipelineCommit.PairWithStock`; a sheet Auto Nest solved with clamps removed takes its +base row's codes. Plates committed on hand-typed stock get none, and a discarded, +rejected or cancelled result changes no codes. The plate keeps them as +`Plate.StockItemCodes` (saved in the nest, see the [nest file format](nest-file-format.md)), +and `/api/nests/{id}/sheets` reports them per plate so MaterialLedger can hold those exact +items. The plate list's tooltip shows "Stock: CODE (location)"; the location comes from +this window's ledger load and is never saved. A re-nest builds new plates, so it drops codes. + **Cost.** Ledger rows load without cost. A ledger `CostPerUnit` is per lot unit, and a drop's is its source sheet's. While any ledger row is ticked, the run scores by area and hand-typed costs are ignored. The summary line says so, and the saved options keep diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index a52edbfe..a6fd6209 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -155,6 +155,23 @@ and sheets with parts, cut-offs, removed clamps or explicit zones are not change An empty plate with other zones, or a rail-following one whose size, quadrant or spacing differs from the saved defaults, is still written. +## Stock item codes + +A plate may carry an optional `stockItemCodes` array: the inventory item (a +MaterialLedger item code) each physical sheet of its `quantity` is cut from, in +sheet order. Entry *i* names sheet *i*; sheets past the end are uncoded, and a +bundle used for several sheets repeats its code. It is written only when at +least one sheet is coded, so files without coded plates are unchanged. A +missing or malformed value loads as no codes; non-string entries are skipped, +a blank entry ends the coded sheets, and entries beyond `quantity` are dropped. +Older readers ignore the key. + +Codes are hand-off data for the inventory system, never quantity accounting. +The model drops them when they stop describing the sheet: resizing a plate +(other than turning it) clears its codes, lowering its quantity truncates them, +and changing the nest's material, grade or thickness clears every plate's. +Auto Nest assigns them only to sheets it nests on ledger stock. + ## Support grid `plateDefaults.supportBed` optionally stores the machine support bed every sheet of diff --git a/docs/nest-storage.md b/docs/nest-storage.md index d1888e94..34d4fe7b 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -102,7 +102,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr | GET | `/api/nests/query?search=&status=&material=&materialGrade=&thickness=&sort=&order=&offset=&limit=` | — | `{ "items": NestRecord[], "total", "offset", "limit" }`: one bounded page filtered in SQL (see below), or 400 for an invalid parameter | | GET | `/api/nests/{id}` | — | `NestRecord` or 404 | | GET | `/api/nests/{id}/file` | — | `.nest` archive bytes (`application/zip`) or 404 | -| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable | +| GET | `/api/nests/{id}/sheets` | — | `{ "nestId", "revision", "sheets": [{ "width", "length", "quantity" }], "parts": [{ "name", "required", "nested" }], "plates": [{ "index", "width", "length", "quantity", "stockItemCodes" }] }` read from the current archive (see below), 404, or 500 problem when the stored archive is unreadable | | POST | `/api/nests` | multipart: `metadata` (JSON `NestRecord`) + `file` (`.nest` bytes) | `NestRecord` with server-assigned `id` when the client sends an empty guid; 409 with the current record (active or deleted) when the `id` already exists, storing nothing | | PUT | `/api/nests/{id}/file` | multipart: `metadata` + `file` | Updated `NestRecord`, or 404 if `id` is unknown | | PUT | `/api/nests/{id}/metadata` | JSON `NestRecord` | Updated `NestRecord` (archive untouched), or 404 | @@ -132,6 +132,15 @@ entry per drawing, ordered by name: `required` is the drawing's required quantity, `nested` its parts on each plate times that plate's quantity; parts whose drawing is missing are skipped. +`plates` lists the same plates one by one, in nest order: `index` (0-based +position), `width` (the shorter side) and `length` as stored, `quantity`, and +`stockItemCodes`, the inventory item each sheet of that plate is cut from +(entry *i* is sheet *i*; sheets past the end are uncoded and a bundle repeats +its code; empty when none is coded). Codes come from the plate's optional +`stockItemCodes` in `nest.json` (see the [nest file format](nest-file-format.md)), +read as `NestReader` reads them. MaterialLedger can hold exactly those items and +match uncoded sheets by size. + ### Generated nest names **File > New** names a nest `N-` (`OpenNest.Data.NestNames`): the