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