diff --git a/OpenNest.Data/NestSetMerge.cs b/OpenNest.Data/NestSetMerge.cs new file mode 100644 index 00000000..2ca70fcc --- /dev/null +++ b/OpenNest.Data/NestSetMerge.cs @@ -0,0 +1,210 @@ +using OpenNest; + +namespace OpenNest.Data; + +/// One library drawing a nest would add to a set, with the nest's required quantity. +public sealed class NestSetCandidate +{ + internal NestSetCandidate(DrawingLibraryLink link, string name, int quantity) + { + Link = link; + Name = name; + Quantity = quantity; + } + + /// The link of the first nest drawing copied from this library drawing. + public DrawingLibraryLink Link { get; } + + public Guid DrawingId => Link.Id; + + /// The nest drawing's name. + public string Name { get; } + + /// The required quantity, summed over every nest drawing copied from this library drawing. + public int Quantity { get; } +} + +/// A nest drawing left out of the set, and why. +public sealed record NestSetSkip(string Name, string Reason); + +/// A drawing the set already lists: its quantity there is kept, not replaced or added to. +public sealed record NestSetKept(string Name, int SetQuantity, int NestQuantity); + +/// What changed in the set editor. +public sealed class NestSetMergeResult +{ + internal NestSetMergeResult(IReadOnlyList added, IReadOnlyList kept, + IReadOnlyList skipped, IReadOnlyList warnings) + { + Added = added; + Kept = kept; + Skipped = skipped; + Warnings = warnings; + } + + /// The drawings appended to the set, in order. + public IReadOnlyList Added { get; } + + public IReadOnlyList Kept { get; } + + /// Every nest drawing left out: those the plan skipped, then those the library no longer holds. + public IReadOnlyList Skipped { get; } + + /// Added drawings the operator should know about (archived, or newer in the library). + public IReadOnlyList Warnings { get; } + + /// True when the operator only needs to review the editor, with nothing else to read first. + public bool IsClean => Kept.Count == 0 && Skipped.Count == 0 && Warnings.Count == 0; + + /// The operator-facing account of what was added, kept, skipped and why. + public string Describe(string setName) + { + var lines = new List(); + var target = string.IsNullOrWhiteSpace(setName) ? "the new set" : $"set '{setName}'"; + lines.Add(Added.Count == 0 + ? $"No drawings were added to {target}." + : $"Added {Added.Count} drawing{(Added.Count == 1 ? "" : "s")} to {target}."); + + if (Kept.Count > 0) + { + lines.Add(""); + lines.Add("Already in the set (its quantity is kept; edit it there to change it):"); + lines.AddRange(Kept.Select(k => $"- {k.Name}: set {k.SetQuantity}, nest {k.NestQuantity}")); + } + + if (Skipped.Count > 0) + { + lines.Add(""); + lines.Add("Skipped:"); + lines.Add(Lines(Skipped)); + } + + if (Warnings.Count > 0) + { + lines.Add(""); + lines.Add("Check:"); + lines.AddRange(Warnings.Select(w => "- " + w)); + } + + return string.Join(Environment.NewLine, lines); + } + + internal static string Lines(IEnumerable skipped) => + string.Join(Environment.NewLine, skipped.Select(s => $"- {s.Name}: {s.Reason}")); +} + +/// +/// Adds a nest's library drawings to a drawing set (Slice 6). is a snapshot of +/// the nest taken before any I/O: each library drawing on this server once, with the required +/// quantities of its nest copies summed. Drawings that were never saved to the library, came from +/// another server or have no required quantity are skipped with a reason; cutoffs are not parts +/// and are left out silently. then reads each drawing's current library +/// record and appends it to a , which the operator reviews and +/// saves. A drawing the set already lists keeps the set's quantity: a nest released from a set +/// carries its multiplier, so copying its quantities back would scale the kit. +/// +public sealed class NestSetMerge +{ + private const string UnlinkedReason = "not in the drawing library (save it to the library first)"; + + private NestSetMerge(IReadOnlyList candidates, IReadOnlyList skipped) + { + Candidates = candidates; + Skipped = skipped; + } + + /// The library drawings to add, in the drawing list's (name) order. + public IReadOnlyList Candidates { get; } + + public IReadOnlyList Skipped { get; } + + /// The skipped drawings, one "- name: reason" line each. + public string DescribeSkipped() => NestSetMergeResult.Lines(Skipped); + + public static NestSetMerge Plan(Nest nest, string serverUrl) + { + ArgumentNullException.ThrowIfNull(nest); + var serverKey = ServerUrl.Key(serverUrl); + if (serverKey.Length == 0) + throw new ArgumentException("A nest server URL is required.", nameof(serverUrl)); + + var skipped = new List(); + var linked = new List<(DrawingLibraryLink Link, string Name, int Quantity)>(); + foreach (var drawing in nest.Drawings.Where(d => d != null && !d.IsCutOff).OrderBy(d => d.Name)) + { + var name = drawing.Name ?? ""; + var link = drawing.Library; + if (link == null) + skipped.Add(new NestSetSkip(name, UnlinkedReason)); + else if (!string.Equals(link.ServerKey, serverKey, StringComparison.Ordinal)) + skipped.Add(new NestSetSkip(name, $"from the drawing library on another server ({link.ServerKey})")); + else if (drawing.Quantity.Required < 1) + skipped.Add(new NestSetSkip(name, "has no required quantity")); + else + linked.Add((link, name, drawing.Quantity.Required)); + } + + var candidates = new List(); + foreach (var group in linked.GroupBy(l => l.Link.Id)) + { + var first = group.First(); + var quantity = group.Sum(l => (long)l.Quantity); + if (quantity > DrawingSetRules.MaxQuantity) + { + skipped.Add(new NestSetSkip(first.Name, + $"quantity {quantity:N0} is more than a set member allows ({DrawingSetRules.MaxQuantity:N0})")); + continue; + } + + candidates.Add(new NestSetCandidate(first.Link, first.Name, (int)quantity)); + } + + return new NestSetMerge(candidates, skipped); + } + + /// + /// Reads each candidate's current library record and appends the drawings + /// does not already list. Nothing is saved; a read failure throws + /// before changes. + /// + public async Task ApplyAsync(IDrawingLibraryStore library, DrawingSetEditSession edit, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(library); + ArgumentNullException.ThrowIfNull(edit); + + var skipped = new List(Skipped); + var kept = new List(); + var found = new List<(NestSetCandidate Candidate, DrawingLibraryRecord Record)>(); + foreach (var candidate in Candidates) + { + var existing = edit.Members.FirstOrDefault(m => m.DrawingId == candidate.DrawingId); + if (existing != null) + { + kept.Add(new NestSetKept(candidate.Name, existing.Quantity, candidate.Quantity)); + continue; + } + + var record = await library.GetAsync(candidate.DrawingId, cancellationToken); + if (record == null) + skipped.Add(new NestSetSkip(candidate.Name, "no longer in the drawing library on this server")); + else + found.Add((candidate, record)); + } + + var added = new List(); + var warnings = new List(); + foreach (var (candidate, record) in found) + { + edit.AddDrawings(new[] { record }, candidate.Quantity); + added.Add(candidate); + if (record.Archived) + warnings.Add($"'{record.Name}' is archived in the library; releases skip it unless it is ticked back in."); + else if (record.Revision > candidate.Link.Revision) + warnings.Add($"'{record.Name}' is at revision {record.Revision} in the library; this nest has revision " + + $"{candidate.Link.Revision}. Sets release the latest revision."); + } + + return new NestSetMergeResult(added, kept, skipped, warnings); + } +} diff --git a/OpenNest.Tests/Data/NestSetMergeTests.cs b/OpenNest.Tests/Data/NestSetMergeTests.cs new file mode 100644 index 00000000..b934ea09 --- /dev/null +++ b/OpenNest.Tests/Data/NestSetMergeTests.cs @@ -0,0 +1,185 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class NestSetMergeTests +{ + private const string Server = "http://synthetic-server:5280"; + + [Fact] + public void Plan_TakesThisServersLinkedDrawings_AndSkipsTheRestWithAReason() + { + var a = SetReleasePlanTests.Drawing("SYN-A"); + var b = SetReleasePlanTests.Drawing("SYN-B"); + var nest = new Nest(); + nest.Drawings.Add(Linked("SYN-B", b, 3)); + nest.Drawings.Add(Linked("SYN-A", a, 2)); + nest.Drawings.Add(Drawing("SYN-LOOSE", 4)); + nest.Drawings.Add(Linked("SYN-OTHER", SetReleasePlanTests.Drawing("SYN-OTHER"), 1, server: "http://other-server")); + nest.Drawings.Add(Linked("SYN-NONE", SetReleasePlanTests.Drawing("SYN-NONE"), 0)); + var cutoff = Drawing("SYN-CUTOFF", 1); + cutoff.IsCutOff = true; + nest.Drawings.Add(cutoff); + + // The trailing slash names the same server. + var merge = NestSetMerge.Plan(nest, Server + "/"); + + Assert.Equal(new[] { (a.Id, "SYN-A", 2), (b.Id, "SYN-B", 3) }, + merge.Candidates.Select(c => (c.DrawingId, c.Name, c.Quantity))); + Assert.Equal(new[] { "SYN-LOOSE", "SYN-NONE", "SYN-OTHER" }, merge.Skipped.Select(s => s.Name)); + Assert.Contains("save it to the library first", merge.Skipped[0].Reason); + Assert.Contains("no required quantity", merge.Skipped[1].Reason); + Assert.Contains("http://other-server", merge.Skipped[2].Reason); + Assert.Equal("- SYN-LOOSE: " + merge.Skipped[0].Reason, merge.DescribeSkipped().Split(Environment.NewLine)[0]); + } + + [Fact] + public void Plan_SumsCopiesOfOneLibraryDrawing_AndSkipsAQuantityPastTheSetLimit() + { + var a = SetReleasePlanTests.Drawing("SYN-A"); + var big = SetReleasePlanTests.Drawing("SYN-BIG"); + var nest = new Nest(); + nest.Drawings.Add(Linked("SYN-A", a, 2)); + nest.Drawings.Add(Linked("SYN-A COPY", a, 5)); + nest.Drawings.Add(Linked("SYN-BIG", big, DrawingSetRules.MaxQuantity)); + nest.Drawings.Add(Linked("SYN-BIG COPY", big, 1)); + + var merge = NestSetMerge.Plan(nest, Server); + + var candidate = Assert.Single(merge.Candidates); + Assert.Equal(("SYN-A", 7), (candidate.Name, candidate.Quantity)); + var skip = Assert.Single(merge.Skipped); + Assert.Equal("SYN-BIG", skip.Name); + Assert.Contains((DrawingSetRules.MaxQuantity + 1).ToString("N0"), skip.Reason); + } + + [Fact] + public async Task Apply_AppendsNewDrawings_KeepsExistingQuantities_AndWarnsWhatToCheck() + { + var kept = SetReleasePlanTests.Drawing("SYN-KEPT"); + var fresh = SetReleasePlanTests.Drawing("SYN-FRESH"); + var newer = SetReleasePlanTests.Drawing("SYN-NEWER"); + var archived = SetReleasePlanTests.Drawing("SYN-ARCHIVED", archived: true); + var gone = SetReleasePlanTests.Drawing("SYN-GONE"); + var library = new Library(kept, fresh, newer, archived); + var nest = new Nest(); + nest.Drawings.Add(Linked("SYN-KEPT", kept, 8)); + nest.Drawings.Add(Linked("SYN-FRESH", fresh, 3)); + nest.Drawings.Add(Linked("SYN-NEWER", newer, 1, revision: newer.Revision - 1)); + nest.Drawings.Add(Linked("SYN-ARCHIVED", archived, 1)); + nest.Drawings.Add(Linked("SYN-GONE", gone, 1)); + nest.Drawings.Add(Drawing("SYN-LOOSE", 1)); + var sets = new InMemoryDrawingSets(); + var set = await sets.CreateAsync(SetReleasePlanTests.Set((kept, 2))); + var edit = new DrawingSetEditSession(sets, await sets.GetAsync(set.Id), "synthetic"); + + var result = await NestSetMerge.Plan(nest, Server).ApplyAsync(library, edit); + + Assert.Equal(new[] { (kept.Id, 2), (archived.Id, 1), (fresh.Id, 3), (newer.Id, 1) }, + edit.Members.Select(m => (m.DrawingId, m.Quantity))); + Assert.Same(fresh, edit.Members[2].Drawing); + Assert.Equal(new[] { "SYN-ARCHIVED", "SYN-FRESH", "SYN-NEWER" }, result.Added.Select(a => a.Name)); + Assert.Equal(new NestSetKept("SYN-KEPT", 2, 8), Assert.Single(result.Kept)); + Assert.Equal(new[] { "SYN-LOOSE", "SYN-GONE" }, result.Skipped.Select(s => s.Name)); + Assert.Equal(2, result.Warnings.Count); + Assert.Contains("'SYN-ARCHIVED' is archived", result.Warnings[0]); + Assert.Contains($"revision {newer.Revision} in the library; this nest has revision {newer.Revision - 1}", result.Warnings[1]); + Assert.False(result.IsClean); + // A member the set already lists is not read from the library. + Assert.Equal(new[] { archived.Id, fresh.Id, gone.Id, newer.Id }, library.Reads); + + var text = result.Describe(set.Name); + Assert.StartsWith($"Added 3 drawings to set '{set.Name}'.", text); + Assert.Contains("- SYN-KEPT: set 2, nest 8", text); + Assert.Contains("- SYN-GONE: no longer in the drawing library", text); + Assert.Contains("Check:", text); + } + + [Fact] + public async Task Apply_ToANewSet_IsCleanWhenEveryDrawingIsCurrent() + { + var a = SetReleasePlanTests.Drawing("SYN-A"); + var nest = new Nest(); + nest.Drawings.Add(Linked("SYN-A", a, 4)); + var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic"); + + var result = await NestSetMerge.Plan(nest, Server).ApplyAsync(new Library(a), edit); + + Assert.True(result.IsClean); + Assert.Equal((a.Id, 4), edit.Members.Select(m => (m.DrawingId, m.Quantity)).Single()); + Assert.Equal("Added 1 drawing to the new set.", result.Describe(null)); + } + + [Fact] + public async Task Apply_LeavesTheEditorUnchanged_WhenALibraryReadFails() + { + var a = SetReleasePlanTests.Drawing("SYN-A"); + var b = SetReleasePlanTests.Drawing("SYN-B"); + var nest = new Nest(); + nest.Drawings.Add(Linked("SYN-A", a, 1)); + nest.Drawings.Add(Linked("SYN-B", b, 1)); + var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic"); + var library = new Library(a, b) { FailOn = b.Id }; + + await Assert.ThrowsAsync(() => NestSetMerge.Plan(nest, Server).ApplyAsync(library, edit)); + + Assert.Empty(edit.Members); + } + + private static Drawing Drawing(string name, int required) + { + var drawing = new Drawing(name); + drawing.Quantity.Required = required; + return drawing; + } + + private static Drawing Linked(string name, DrawingLibraryRecord record, int required, string server = Server, + int? revision = null) + { + var drawing = Drawing(name, required); + drawing.Library = new DrawingLibraryLink(server, record.Id, revision ?? record.Revision, record.CutType, + record.Thickness, record.MaterialName, record.MaterialGrade, record.Units, record.Notes, ""); + return drawing; + } + + private sealed class Library : IDrawingLibraryStore + { + private readonly Dictionary _records; + + public Library(params DrawingLibraryRecord[] records) => _records = records.ToDictionary(r => r.Id); + + public List Reads { get; } = new(); + + public Guid FailOn { get; init; } + + public Task GetAsync(Guid id, CancellationToken cancellationToken = default) + { + Reads.Add(id); + if (id == FailOn) + throw new IOException("Synthetic connection refused."); + return Task.FromResult(_records.GetValueOrDefault(id)); + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UploadAsync(DrawingLibraryRecord record, byte[] package, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs index e0f3d1eb..691ebb62 100644 --- a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs +++ b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs @@ -350,6 +350,113 @@ public class DrawingSetFormsTests "The set history test did not complete."); } + [Fact] + public void NestDrawingsToSet_AppendsTheNestsLibraryDrawings_KeepsSetQuantities_AndReportsSkips() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + var existing = library.Add("SYN-A", "Synthetic Steel", 0.25); + var added = library.Add("SYN-B", "Synthetic Steel", 0.25); + var sets = new InMemoryDrawingSets(); + var set = Run(sets.CreateAsync(new DrawingSetRecord + { + Id = Guid.NewGuid(), + Name = "Synthetic kit", + Members = new[] { new DrawingSetMember { DrawingId = existing.Id, Quantity = 1, Drawing = existing } }, + })); + var nest = MatchingNest(); + nest.Drawings.Add(LinkedDrawing(existing, 6)); + nest.Drawings.Add(LinkedDrawing(added, 4)); + nest.Drawings.Add(new Drawing("SYN-LOOSE") { Quantity = { Required = 2 } }); + var messages = new List(); + + var command = new NestDrawingsToSet(null, sets, library, Server); + command.ShowMessage = (text, buttons, _) => + { + messages.Add(text); + Assert.Equal(MessageBoxButtons.OKCancel, buttons); + return DialogResult.OK; + }; + command.ChooseSet = () => (true, set); + command.EditSet = edit => Run(edit.SaveAsync()); + var saved = Run(command.RunAsync(nest)); + + Assert.Equal(2, saved.Revision); + Assert.Equal(new[] { (existing.Id, 1), (added.Id, 4) }, saved.Members.Select(m => (m.DrawingId, m.Quantity))); + var message = Assert.Single(messages); + Assert.StartsWith("Added 1 drawing to set 'Synthetic kit'.", message); + Assert.Contains("- SYN-A: set 1, nest 6", message); + Assert.Contains("- SYN-LOOSE: not in the drawing library", message); + }, + TestTimeout, + "The nest drawings to set test did not complete."); + } + + [Fact] + public void NestDrawingsToSet_NamesANewSetAfterTheNest_AndAsksNothingWhenEveryDrawingIsCurrent() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + var a = library.Add("SYN-A", "Synthetic Steel", 0.25); + var sets = new InMemoryDrawingSets(); + var nest = MatchingNest(); + nest.Customer = "Synthetic customer"; + nest.Drawings.Add(LinkedDrawing(a, 3)); + DrawingSetEditSession edited = null; + + var command = new NestDrawingsToSet(null, sets, library, Server); + command.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + command.ChooseSet = () => (true, null); + command.EditSet = edit => + { + edited = edit; + return null; + }; + Assert.Null(Run(command.RunAsync(nest))); + + Assert.True(edited!.IsNew); + Assert.Equal(("Synthetic job", "Synthetic customer"), (edited.Name, edited.Customer)); + Assert.Equal((a.Id, 3), edited.Members.Select(m => (m.DrawingId, m.Quantity)).Single()); + Assert.Empty(sets.Creates); + }, + TestTimeout, + "The nest drawings to new set test did not complete."); + } + + [Fact] + public void NestDrawingsToSet_WithoutLibraryDrawings_SaysSoBeforeChoosingASet() + { + StaTestThread.Run( + () => + { + var nest = MatchingNest(); + nest.Drawings.Add(new Drawing("SYN-LOOSE") { Quantity = { Required = 1 } }); + var messages = new List(); + + var command = new NestDrawingsToSet(null, new InMemoryDrawingSets(), new FakeLibrary(), Server); + command.ShowMessage = (text, _, _) => { messages.Add(text); return DialogResult.OK; }; + command.ChooseSet = () => throw new Xunit.Sdk.XunitException("No set should be chosen."); + Assert.Null(Run(command.RunAsync(nest))); + + var message = Assert.Single(messages); + Assert.Contains("Save them to the drawing library first", message); + Assert.Contains("- SYN-LOOSE:", message); + }, + TestTimeout, + "The nest without library drawings test did not complete."); + } + + private static Drawing LinkedDrawing(DrawingLibraryRecord record, int required) => new(record.Name) + { + Quantity = { Required = required }, + Library = new DrawingLibraryLink(Server, record.Id, record.Revision, record.CutType, record.Thickness, + record.MaterialName, record.MaterialGrade, record.Units, record.Notes, ""), + }; + private static Nest MatchingNest() => new("Synthetic job") { Units = Units.Inches, @@ -421,7 +528,7 @@ public class DrawingSetFormsTests throw new NotSupportedException(); public Task GetAsync(Guid id, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + Task.FromResult(_records.FirstOrDefault(r => r.Id == id)); public Task GetRevisionsAsync(Guid id, int offset = 0, int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) => diff --git a/OpenNest/Controls/DrawingListBox.cs b/OpenNest/Controls/DrawingListBox.cs index 1c8834dd..159d66ae 100644 --- a/OpenNest/Controls/DrawingListBox.cs +++ b/OpenNest/Controls/DrawingListBox.cs @@ -35,6 +35,8 @@ namespace OpenNest.Controls if (SelectedItem is Drawing drawing) SaveToLibraryRequested?.Invoke(this, drawing); }; + var addToSetItem = new ToolStripMenuItem("Add Library Drawings to Set..."); + addToSetItem.Click += (s, e) => AddToSetRequested?.Invoke(this, EventArgs.Empty); var saveToLibrarySeparator = new ToolStripSeparator(); var openInConverterItem = new ToolStripMenuItem("Open in CAD Converter..."); openInConverterItem.Click += (s, e) => @@ -56,9 +58,13 @@ namespace OpenNest.Controls saveToLibraryItem.Visible = saveToLibrarySeparator.Visible = SaveToLibraryRequested != null; saveToLibraryItem.Enabled = SaveToLibraryUnavailableReason == null; saveToLibraryItem.ToolTipText = SaveToLibraryUnavailableReason ?? ""; + addToSetItem.Visible = AddToSetRequested != null; + addToSetItem.Enabled = SaveToLibraryUnavailableReason == null; + addToSetItem.ToolTipText = SaveToLibraryUnavailableReason ?? ""; openInConverterItem.Visible = openInConverterSeparator.Visible = OpenInConverterRequested != null; }; menu.Items.Add(saveToLibraryItem); + menu.Items.Add(addToSetItem); menu.Items.Add(saveToLibrarySeparator); menu.Items.Add(openInConverterItem); menu.Items.Add(openInConverterSeparator); @@ -71,7 +77,16 @@ namespace OpenNest.Controls /// Raised when Save to Drawing Library is chosen for the selected drawing. public event EventHandler SaveToLibraryRequested; - /// Why Save to Drawing Library is disabled, shown as its tooltip; null enables it. + /// + /// Raised when Add Library Drawings to Set is chosen; it acts on the whole nest, not the + /// selected drawing. + /// + public event EventHandler AddToSetRequested; + + /// + /// Why Save to Drawing Library and Add Library Drawings to Set are disabled, shown as their + /// tooltip; null enables them. + /// public string SaveToLibraryUnavailableReason { get; set; } /// Raised when Open in CAD Converter is chosen for the selected drawing. diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index dafc0c16..5cfcffdb 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -46,6 +46,7 @@ namespace OpenNest.Forms private readonly Timer updateDrawingListTimer; private bool updatingPlateList; + private bool addingToSet; private CutSimulatorForm cutSimulatorForm; private Panel plateHeaderPanel; @@ -444,6 +445,7 @@ namespace OpenNest.Forms drawingListBox1.Units = Nest.Units; drawingListBox1.DeleteRequested += drawingListBox1_DeleteRequested; drawingListBox1.SaveToLibraryRequested += drawingListBox1_SaveToLibraryRequested; + drawingListBox1.AddToSetRequested += drawingListBox1_AddToSetRequested; drawingListBox1.OpenInConverterRequested += drawingListBox1_OpenInConverterRequested; drawingListBox1.DrawingPickedUp += drawingListBox1_DrawingPickedUp; drawingListBox1.DrawingDropped += drawingListBox1_DrawingDropped; @@ -1367,6 +1369,44 @@ namespace OpenNest.Forms UpdateDrawingList(); } + private async void drawingListBox1_AddToSetRequested(object sender, EventArgs e) + { + await AddLibraryDrawingsToSetAsync(); + } + + /// + /// Adds the nest's library drawings, with their required quantities, to a chosen or new + /// drawing set, which the operator reviews and saves in the set editor. + /// + public async Task AddLibraryDrawingsToSetAsync() + { + if (addingToSet || !NestStorage.Settings.IsDatabaseMode) + return; + + IDrawingLibraryStore library; + IDrawingSetStore sets; + try + { + library = NestStorage.CreateDrawingLibrary(); + sets = NestStorage.CreateDrawingSetStore(); + } + catch (ArgumentException ex) + { + MessageBox.Show(this, ex.Message, "Drawing Sets", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + addingToSet = true; + try + { + await new NestDrawingsToSet(this, sets, library, NestStorage.Settings.ServerUrl).RunAsync(Nest); + } + finally + { + addingToSet = false; + } + } + private void EditDrawingsInConverter_Click(object sender, EventArgs e) { if (Nest.Drawings.Count == 0) diff --git a/OpenNest/Forms/NestDrawingsToSet.cs b/OpenNest/Forms/NestDrawingsToSet.cs new file mode 100644 index 00000000..c34e820f --- /dev/null +++ b/OpenNest/Forms/NestDrawingsToSet.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using System.Windows.Forms; +using OpenNest.Data; + +namespace OpenNest.Forms +{ + /// + /// Drawing list > Add Library Drawings to Set: chooses a set (or a new one), appends the + /// nest's library drawings with their required quantities through , + /// reports anything kept, skipped or worth checking, then opens the set editor so the + /// operator reviews and saves. Nothing is stored until the editor saves. + /// + internal sealed class NestDrawingsToSet + { + private const string Caption = "Add Library Drawings to Set"; + + private readonly IDrawingSetStore sets; + private readonly IDrawingLibraryStore library; + private readonly string serverUrl; + + /// Shows a message and returns the operator's answer; replaced by tests. + internal Func ShowMessage { get; set; } + + /// Chooses the set: Ok false when cancelled, otherwise the listed set or null for a new one; replaced by tests. + internal Func<(bool Ok, DrawingSetRecord Listed)> ChooseSet { get; set; } + + /// Shows the set editor and returns the saved record, or null; replaced by tests. + internal Func EditSet { get; set; } + + public NestDrawingsToSet(IWin32Window owner, IDrawingSetStore sets, IDrawingLibraryStore library, string serverUrl) + { + this.sets = sets ?? throw new ArgumentNullException(nameof(sets)); + this.library = library ?? throw new ArgumentNullException(nameof(library)); + this.serverUrl = serverUrl ?? ""; + ShowMessage = (text, buttons, icon) => MessageBox.Show(owner, text, Caption, buttons, icon); + ChooseSet = () => + { + using var form = new ChooseDrawingSetForm(sets, library, this.serverUrl); + return form.ShowDialog(owner) == DialogResult.OK ? (true, form.PickedSet) : (false, null); + }; + EditSet = edit => + { + using var form = new EditDrawingSetForm(edit, library, this.serverUrl); + return form.ShowDialog(owner) == DialogResult.OK ? form.Saved : null; + }; + } + + /// + /// Runs the command for , planned before the first prompt so later + /// changes to the nest do not reach the set. Returns the saved set, or null. + /// + public async Task RunAsync(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + var merge = NestSetMerge.Plan(nest, serverUrl); + if (merge.Candidates.Count == 0) + { + var text = "None of this nest's drawings can be added to a set. Save them to the drawing library first."; + if (merge.Skipped.Count > 0) + text += Environment.NewLine + Environment.NewLine + "Skipped:" + Environment.NewLine + merge.DescribeSkipped(); + ShowMessage(text, MessageBoxButtons.OK, MessageBoxIcon.Information); + return null; + } + + var (ok, listed) = ChooseSet(); + if (!ok) + return null; + + DrawingSetEditSession edit; + NestSetMergeResult result; + try + { + DrawingSetRecord current = null; + if (listed != null) + { + current = await sets.GetAsync(listed.Id); + if (current == null || current.Archived) + { + ShowMessage(current == null + ? $"Set '{listed.Name}' no longer exists on the server." + : $"Set '{listed.Name}' was archived. Restore it before adding drawings.", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return null; + } + } + + edit = new DrawingSetEditSession(sets, current, Environment.UserName); + if (current == null) + { + edit.Name = nest.Name ?? ""; + edit.Customer = nest.Customer ?? ""; + } + result = await merge.ApplyAsync(library, edit); + } + catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException) + { + // ServerTooOldException is an IOException too. + ShowMessage($"Could not read the drawing library: {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Error); + return null; + } + + var setName = listed?.Name; + if (result.Added.Count == 0) + { + ShowMessage(result.Describe(setName), MessageBoxButtons.OK, MessageBoxIcon.Information); + return null; + } + + if (!result.IsClean + && ShowMessage(result.Describe(setName) + Environment.NewLine + Environment.NewLine + + "Review and save the set?", MessageBoxButtons.OKCancel, MessageBoxIcon.Information) != DialogResult.OK) + return null; + + return EditSet(edit); + } + } +} diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 2206dc9f..d5239095 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -360,6 +360,20 @@ whose current revision contains it) or **Add to Set...**: choose an active set or **New Set**, and the set editor opens with the drawing added at the entered quantity for review and Save. +The nest's drawing list has **Add Library Drawings to Set...** (right-click any +drawing; it acts on the whole nest, `OpenNest.Data.NestSetMerge`). It takes each +drawing copied from this server's library once, in list order, with its required +quantity (copies of one library drawing are summed). Drawings never saved to the +library, linked to another server or with no required quantity are skipped and +named; cutoffs are left out. If none remain it says so before anything else. +Otherwise choose an active set or **New Set** (named after the nest, with its +customer): the set's current library records are read, drawings the set already +lists **keep the set's quantity** (a released nest carries its multiplier, so +copying its quantities back would scale the kit), and members are added at the +library's latest revision. Anything kept, skipped, archived in the library or +newer there than in the nest is listed first, then the set editor opens for +review and Save; nothing is stored before then. + **Release...** (the Sets tab, when the dialog was opened from a nest) reads the selected active set with its members and opens the release dialog. It lists one row per material group (`OpenNest.Data.SetReleasePlan`): material, grade,