From 74203483c559d1bfcd61db788fcbbb7b227a6cc9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 21 Oct 2026 09:30:21 -0400 Subject: [PATCH] feat(data): release several sets in one plan and merge groups into draft nests The release plan now takes several sets, each with its own kit count. A drawing listed by more than one set becomes one item with the quantities summed and each set's share kept, so the release can record what every kit put into every nest. FindDraftsAsync lists the Draft nests of a group's material through the new nest query filters. The session can merge a group into a chosen draft instead of making a new nest. It downloads the draft at the revision it was chosen at, refuses it unless it is still a Draft of the group's material, thickness, units and cut type, adds the items, and removes every plate so the whole demand is nested again. The save is guarded by that revision, so a draft someone saved meanwhile fails its group instead of being overwritten. An optional renest step, run before anything is uploaded, will back "Nest after release"; if it throws, the nest is still released without a layout. The release record now carries every set line, merged nests with their revisions before and after, and each set's drawing quantities. SetReleaseText.UndoRefusal words a refused undo for the release history. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/SetReleasePlan.cs | 162 +++++++++---- OpenNest.Data/SetReleaseSession.cs | 214 +++++++++++++++--- OpenNest.Data/SetReleaseText.cs | 21 ++ .../SetReleaseEndToEndTests.cs | 55 +++++ OpenNest.Tests/Data/SetReleasePlanTests.cs | 39 +++- OpenNest.Tests/Data/SetReleaseSessionTests.cs | 212 ++++++++++++++++- OpenNest.Tests/Data/SetReleaseTextTests.cs | 21 ++ OpenNest/Forms/ReleaseSetForm.cs | 2 +- docs/nest-storage.md | 66 ++++-- 9 files changed, 690 insertions(+), 102 deletions(-) diff --git a/OpenNest.Data/SetReleasePlan.cs b/OpenNest.Data/SetReleasePlan.cs index d1e96c60..8572397a 100644 --- a/OpenNest.Data/SetReleasePlan.cs +++ b/OpenNest.Data/SetReleasePlan.cs @@ -3,22 +3,39 @@ using OpenNest; namespace OpenNest.Data; -/// One set member as released: its drawing's current record and the multiplied quantity. +/// One set of a release plan: a set read from the server and how many kits of it. +public sealed record SetReleasePlanLine(DrawingSetRecord Set, int Quantity); + +/// How many of an item one set of the release asked for. +public sealed record SetReleaseItemSource(Guid SetId, int Quantity); + +/// +/// One library drawing as released: its current record and the quantity summed over every +/// set of the release that lists it, kits included. +/// public sealed class SetReleaseItem { - internal SetReleaseItem(DrawingSetMember member, int quantity) + internal SetReleaseItem(DrawingSetMember member, DrawingLibraryRecord drawing, IReadOnlyList sources) { Member = member; - Drawing = member.Drawing!; - Quantity = quantity; + Drawing = drawing; + Sources = sources; + Quantity = checked(sources.Sum(s => s.Quantity)); } + /// The member that first listed the drawing, in set order. public DrawingSetMember Member { get; } - /// The drawing's current library record, as joined by the server when the set was read. + /// + /// The drawing's current library record, as joined by the server when the sets were read; + /// the newest revision when sets read at different times disagree. + /// public DrawingLibraryRecord Drawing { get; } - /// The member quantity times the release multiplier. + /// Each set's share: its member quantity times its kits, in set order. + public IReadOnlyList Sources { get; } + + /// The total over . public int Quantity { get; } /// The drawing is archived and was ticked back in. @@ -83,29 +100,32 @@ public sealed class SetReleaseGroup } /// -/// How a drawing set revision splits into nests, before anything is downloaded or created. -/// Pure: it reads the members' joined drawing records only. Members are grouped by -/// material name and grade (compared by ), thickness -/// (rounded as the library stores it), units and cut type, and every quantity is -/// multiplied. Archived drawings are skipped unless included (S5). A group is refused when -/// two of its drawings share a name, because a nest keys drawings by name (S6). +/// How one or more drawing set revisions split into nests, before anything is downloaded or +/// created. Pure: it reads the members' joined drawing records only. A drawing listed by +/// several sets becomes one item with the quantities summed, each set's share kept. Items are +/// grouped by material name and grade (compared by ), +/// thickness (rounded as the library stores it), units and cut type. Archived drawings are +/// skipped unless included (S5). A group is refused when two of its drawings share a name, +/// because a nest keys drawings by name (S6). /// public sealed class SetReleasePlan { - private SetReleasePlan(DrawingSetRecord set, int multiplier, bool includeArchived, + private SetReleasePlan(IReadOnlyList lines, bool includeArchived, IReadOnlyList groups, IReadOnlyList skippedArchived) { - Set = set; - Multiplier = multiplier; + Lines = lines; IncludeArchived = includeArchived; Groups = groups; SkippedArchived = skippedArchived; + var customers = lines.Select(l => (l.Set.Customer ?? "").Trim()).Distinct(StringComparer.Ordinal).ToList(); + Customer = customers.Count == 1 ? customers[0] : ""; } - /// The set revision being released, as read from the server. - public DrawingSetRecord Set { get; } + /// The sets released and how many kits of each, as read from the server. + public IReadOnlyList Lines { get; } - public int Multiplier { get; } + /// The customer every set names, or blank when they differ. + public string Customer { get; } public bool IncludeArchived { get; } @@ -119,33 +139,68 @@ public sealed class SetReleasePlan public IReadOnlyList SkippedArchived { get; } /// A set read from the server, so every member carries its drawing's current record. - public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived) + public static SetReleasePlan Build(DrawingSetRecord set, int multiplier, bool includeArchived) => + Build(new[] { new SetReleasePlanLine(set, multiplier) }, includeArchived); + + /// Sets read from the server, so every member carries its drawing's current record. + public static SetReleasePlan Build(IReadOnlyList lines, bool includeArchived) { - ArgumentNullException.ThrowIfNull(set); - if (multiplier < 1 || multiplier > SetReleaseRules.MaxQuantity) - throw new ArgumentOutOfRangeException(nameof(multiplier), multiplier, - $"The multiplier must be between 1 and {SetReleaseRules.MaxQuantity}."); - if (set.Id == Guid.Empty || set.Revision < 1) - throw new ArgumentException("Only a set read from the server can be released.", nameof(set)); - var members = set.Members ?? throw new ArgumentException("The set has no member list.", nameof(set)); - if (members.Any(m => m?.Drawing is null || m.Drawing.Id != m.DrawingId)) - throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(set)); - - var skipped = new List(); - var items = new List<(string Key, bool Unassigned, SetReleaseItem Item)>(); - foreach (var member in members) + ArgumentNullException.ThrowIfNull(lines); + if (lines.Count == 0 || lines.Count > SetReleaseRules.MaxSetLines) + throw new ArgumentException($"Release between 1 and {SetReleaseRules.MaxSetLines} sets.", nameof(lines)); + var seenSets = new HashSet(); + foreach (var line in lines) { - if (member.Drawing!.Archived && !includeArchived) - { - skipped.Add(member); - continue; - } - - var unassigned = IsUnassigned(member.Drawing); - items.Add((unassigned ? UnassignedKey : GroupKey(member.Drawing), unassigned, - new SetReleaseItem(member, member.Quantity * multiplier))); + var set = line?.Set ?? throw new ArgumentException("Every line needs a set.", nameof(lines)); + if (line.Quantity < 1 || line.Quantity > SetReleaseRules.MaxQuantity) + throw new ArgumentOutOfRangeException(nameof(lines), line.Quantity, + $"The number of kits must be between 1 and {SetReleaseRules.MaxQuantity}."); + if (set.Id == Guid.Empty || set.Revision < 1) + throw new ArgumentException("Only a set read from the server can be released.", nameof(lines)); + if (!seenSets.Add(set.Id)) + throw new ArgumentException($"Set '{set.Name}' is listed twice. Release it once with more kits.", nameof(lines)); + var members = set.Members ?? throw new ArgumentException("The set has no member list.", nameof(lines)); + if (members.Any(m => m?.Drawing is null || m.Drawing.Id != m.DrawingId)) + throw new ArgumentException("A set member is missing its library drawing. Reload the set.", nameof(lines)); } + // One entry per drawing, in first-listed order; each set's share kept. + var skipped = new List(); + var order = new List(); + var entries = new Dictionary Sources)>(); + foreach (var line in lines) + { + foreach (var member in line.Set.Members) + { + if (member.Drawing!.Archived && !includeArchived) + { + skipped.Add(member); + continue; + } + + var share = new SetReleaseItemSource(line.Set.Id, checked(member.Quantity * line.Quantity)); + if (entries.TryGetValue(member.DrawingId, out var entry)) + { + entry.Sources.Add(share); + if (member.Drawing.Revision > entry.Drawing.Revision) + entries[member.DrawingId] = (entry.Member, member.Drawing, entry.Sources); + } + else + { + order.Add(member.DrawingId); + entries[member.DrawingId] = (member, member.Drawing, new List { share }); + } + } + } + + var items = order.Select(id => + { + var (member, drawing, sources) = entries[id]; + var unassigned = IsUnassigned(drawing); + return (Key: unassigned ? UnassignedKey : GroupKey(drawing), Unassigned: unassigned, + Item: new SetReleaseItem(member, drawing, sources)); + }).ToList(); + var groups = items .GroupBy(i => i.Key, StringComparer.Ordinal) .Select(g => BuildGroup(g.Key, g.First().Unassigned, g.Select(i => i.Item).ToList())) @@ -156,7 +211,30 @@ public sealed class SetReleasePlan .ThenBy(g => g.Units) .ThenBy(g => g.CutType) .ToList(); - return new SetReleasePlan(set, multiplier, includeArchived, groups, skipped); + return new SetReleasePlan(lines.ToList(), includeArchived, groups, skipped); + } + + /// + /// Draft nests on the server that could merge into: not deleted, + /// status Draft, and the group's material, grade and thickness, newest saved first. Units + /// and cut type are not in nest metadata, so the release checks them on the downloaded nest. + /// + public static async Task> FindDraftsAsync(INestRepository nests, SetReleaseGroup group, + int limit = 50, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(nests); + ArgumentNullException.ThrowIfNull(group); + if (!group.CanRelease) + return Array.Empty(); + var page = await nests.QueryAsync(new NestQuery + { + Status = NestStatus.Draft, + Material = group.MaterialName, + MaterialGrade = group.MaterialGrade, + Thickness = group.Thickness, + Limit = limit, + }, cancellationToken); + return page.Items; } /// The group key of a drawing that has a material and thickness. diff --git a/OpenNest.Data/SetReleaseSession.cs b/OpenNest.Data/SetReleaseSession.cs index 56b24529..4470a15c 100644 --- a/OpenNest.Data/SetReleaseSession.cs +++ b/OpenNest.Data/SetReleaseSession.cs @@ -4,7 +4,10 @@ namespace OpenNest.Data; public enum SetReleaseGroupOutcome { - /// The group's nest is on the server; is its record. + /// + /// The group's nest is on the server, new or merged; + /// is its record. + /// Created, /// The group could not be built; nothing was uploaded for any group. @@ -35,9 +38,15 @@ public sealed class SetReleaseGroupResult /// public Nest? Nest { get; internal set; } - /// The server record of a created nest: its id and revision. + /// The server record of a created or merged nest: its id and the revision the release wrote. public NestRecord? Record { get; internal set; } + /// The draft the group merged into, as chosen (its revision before the merge); null for a new nest. + public NestRecord? MergedInto { get; internal set; } + + /// What nesting after release did; null when it was not asked for. + public SetReleaseRenest? Renest { get; internal set; } + /// Why the group was refused or failed; empty otherwise. public string Error { get; internal set; } = ""; @@ -45,12 +54,21 @@ public sealed class SetReleaseGroupResult public IReadOnlyList Warnings { get; internal set; } = Array.Empty(); } +/// +/// What nesting a group after release did: when a layout was kept, +/// and an operator-facing note either way (unplaced parts, or why nothing was kept). +/// +public sealed record SetReleaseRenest(bool Nested, string Note); + public enum SetReleaseStage { - /// Downloading the group's drawings and building its nest; nothing is uploaded yet. + /// + /// Downloading the group's drawings (and draft), building its nest and nesting it when + /// asked; nothing is uploaded yet. + /// Preparing, - /// Uploading the group's nest. + /// Uploading the group's nest, or saving the draft it merged into. Uploading, /// Every nest is uploaded; the release record is being posted. @@ -115,10 +133,16 @@ public sealed class SetReleaseResult } /// -/// Releases a drawing set: one new nest per chosen group of a , -/// each built through from the members' exact library -/// revisions, uploaded as a , and only then recorded as one -/// release (S7). +/// Releases one or more drawing sets: one nest per chosen group of a , +/// either new (uploaded as a ) or an existing draft of the same +/// group that the release merges into, each built through +/// from the items' exact library revisions, and only then recorded as one release (S7). +/// +/// A merge downloads the draft at the revision it was chosen at, adds the items, and removes +/// every plate, so the whole demand is nested again (M5); it is saved only while the draft is +/// still at that revision. When the caller supplies a renest step, every chosen group, new or +/// merged, is nested before anything is uploaded. +/// /// /// Every group is built before any is uploaded, so a refusal (a package that no longer /// matches its record, units the new nest does not use, an insert refusal) creates @@ -137,6 +161,8 @@ public sealed class SetReleaseSession private readonly Func _readPackage; private readonly Func _writeNest; private readonly Func> _createNest; + private readonly Func? _readNest; + private readonly Func>? _renest; private bool _started; private SetReleaseRecord? _pendingRelease; @@ -146,10 +172,19 @@ public sealed class SetReleaseSession /// Makes an empty nest for a group, named and set up as File > New would, in the group's /// units. The session then sets its material, grade, thickness, customer and status. /// + /// Reads a downloaded nest archive (the desktop uses NestReader); required to merge. + /// + /// Nests a group's nest from scratch (the desktop runs Auto Nest headless): every drawing's + /// required quantity onto plates from the nest's defaults. Null skips nesting after release. + /// It must leave the nest without plates when it keeps no layout. + /// public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl, Func readPackage, Func writeNest, - Func> createNest) + Func> createNest, + Func? readNest = null, Func>? renest = null) { + _readNest = readNest; + _renest = renest; _sets = sets ?? throw new ArgumentNullException(nameof(sets)); _library = library ?? throw new ArgumentNullException(nameof(library)); _nests = nests ?? throw new ArgumentNullException(nameof(nests)); @@ -164,11 +199,16 @@ public sealed class SetReleaseSession /// Every nest was created but the release was not recorded; may post it. public bool HasPendingRelease => _pendingRelease != null; - /// Releases , all from . Call once per session. + /// + /// Releases , all from . A group named in + /// merges into that draft (as read, at that revision) instead + /// of making a new nest. Call once per session. + /// public async Task RunAsync(SetReleasePlan plan, IReadOnlyCollection groups, string releasedBy, string notes, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, IReadOnlyDictionary? mergeInto = null) { + mergeInto ??= new Dictionary(); ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(groups); if (groups.Count == 0) @@ -181,11 +221,22 @@ public sealed class SetReleaseSession ?? DrawingLibraryRules.TextError("notes", notes ?? "", DrawingLibraryRules.MaxNotesLength); if (textError is not null) throw new ArgumentException(textError); + if (mergeInto.Keys.Any(g => !groups.Contains(g))) + throw new ArgumentException("Every merged group must be one of the groups released.", nameof(mergeInto)); + if (mergeInto.Values.Any(d => d is null || d.Id == Guid.Empty || d.Revision < 1)) + throw new ArgumentException("Merge only into a draft read from the server.", nameof(mergeInto)); + if (mergeInto.Values.Select(d => d.Id).Distinct().Count() != mergeInto.Count) + throw new ArgumentException("Two groups cannot merge into the same nest.", nameof(mergeInto)); + if (mergeInto.Count > 0 && _readNest is null) + throw new InvalidOperationException("Merging into a draft needs a nest reader."); if (_started) throw new InvalidOperationException("This release session has already run."); _started = true; - var results = plan.Groups.Where(groups.Contains).Select(g => new SetReleaseGroupResult(g)).ToList(); + var results = plan.Groups.Where(groups.Contains).Select(g => new SetReleaseGroupResult(g) + { + MergedInto = mergeInto.TryGetValue(g, out var draft) ? draft : null, + }).ToList(); // Build every nest first: a refusal here leaves nothing on the server. for (var i = 0; i < results.Count; i++) @@ -236,24 +287,27 @@ public sealed class SetReleaseSession _pendingRelease = new SetReleaseRecord { Id = Guid.NewGuid(), - Sets = new[] + Sets = plan.Lines.Select(l => new SetReleaseLine { - new SetReleaseLine { SetId = plan.Set.Id, SetRevision = plan.Set.Revision, Quantity = plan.Multiplier }, - }, + SetId = l.Set.Id, + SetRevision = l.Set.Revision, + Quantity = l.Quantity, + }).ToList(), Notes = notes ?? "", ReleasedBy = releasedBy ?? "", Nests = results.Select(r => new SetReleaseNest { NestId = r.Record!.Id, - Kind = SetReleaseNestKind.Created, + Kind = r.MergedInto is null ? SetReleaseNestKind.Created : SetReleaseNestKind.Merged, + RevisionBefore = r.MergedInto?.Revision, RevisionAfter = r.Record.Revision, - Items = r.Group.Items.Select(i => new SetReleaseNestItem + Items = r.Group.Items.SelectMany(i => i.Sources.Select(source => new SetReleaseNestItem { - SetId = plan.Set.Id, + SetId = source.SetId, DrawingId = i.Drawing.Id, DrawingRevision = i.Drawing.Revision, - Quantity = i.Quantity, - }).ToList(), + Quantity = source.Quantity, + })).ToList(), MaterialName = r.Group.MaterialName, MaterialGrade = r.Group.MaterialGrade, Thickness = r.Group.Thickness, @@ -285,24 +339,52 @@ public sealed class SetReleaseSession return PostPendingAsync(cancellationToken); } - // Builds the group's nest in memory; false when the group is refused. + // Builds the group's nest in memory, new or merged, and nests it when asked; false when + // the group is refused. private async Task PrepareAsync(SetReleasePlan plan, SetReleaseGroupResult result, CancellationToken cancellationToken) { var group = result.Group; - var nest = await _createNest(group, cancellationToken) - ?? throw new InvalidOperationException("The nest factory returned no nest."); - if (nest.Units != group.Units) - return Refuse(result, $"The new nest for {Describe(group)} is in {Name(nest.Units)}, not {Name(group.Units)}."); + Nest nest; + if (result.MergedInto is { } draft) + { + var archive = await _nests.GetRevisionFileAsync(draft.Id, draft.Revision, cancellationToken); + if (archive == null) + return Refuse(result, $"Draft '{draft.Name}' revision {draft.Revision} is no longer on the server. Choose again."); + nest = await Task.Run(() => _readNest!(archive), cancellationToken); + NestRecordFactory.ApplyMetadata(nest, draft); + if (draft.Status != NestStatus.Draft || draft.Deleted) + return Refuse(result, $"'{draft.Name}' is not a draft, so the release cannot merge into it."); + if (nest.Units != group.Units) + return Refuse(result, $"Draft '{draft.Name}' is in {Name(nest.Units)}, not {Name(group.Units)}."); + if (SharedListNames.Key(nest.Material?.Name) != SharedListNames.Key(group.MaterialName) + || SharedListNames.Key(nest.Material?.Grade) != SharedListNames.Key(group.MaterialGrade) + || DrawingLibraryRules.NormalizeThickness(nest.Thickness) != group.Thickness) + return Refuse(result, $"Draft '{draft.Name}' is not {Describe(group)}."); + if (nest.Drawings.FirstOrDefault(d => d.Library is { } link && link.CutType != group.CutType) is { } other) + return Refuse(result, $"Draft '{draft.Name}' holds '{other.Name}' for {other.Library!.CutType.ToString().ToLowerInvariant()} " + + $"cutting, not {group.CutType.ToString().ToLowerInvariant()}."); - var current = nest.Material; - nest.Material = current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(group.MaterialName) - && SharedListNames.Key(current.Grade) == SharedListNames.Key(group.MaterialGrade) - ? new Material(group.MaterialName, group.MaterialGrade, current.Density) - : new Material(group.MaterialName, group.MaterialGrade); - nest.Thickness = group.Thickness; - nest.Status = NestStatus.Draft; - if (!string.IsNullOrWhiteSpace(plan.Set.Customer)) - nest.Customer = plan.Set.Customer; + // M5: a merge renests the whole demand, so the draft's layout goes. + nest.Plates.Clear(); + nest.UpdateDrawingQuantities(); + } + else + { + nest = await _createNest(group, cancellationToken) + ?? throw new InvalidOperationException("The nest factory returned no nest."); + if (nest.Units != group.Units) + return Refuse(result, $"The new nest for {Describe(group)} is in {Name(nest.Units)}, not {Name(group.Units)}."); + + var current = nest.Material; + nest.Material = current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(group.MaterialName) + && SharedListNames.Key(current.Grade) == SharedListNames.Key(group.MaterialGrade) + ? new Material(group.MaterialName, group.MaterialGrade, current.Density) + : new Material(group.MaterialName, group.MaterialGrade); + nest.Thickness = group.Thickness; + nest.Status = NestStatus.Draft; + if (plan.Customer.Length > 0) + nest.Customer = plan.Customer; + } var warnings = group.Warnings.ToList(); foreach (var item in group.Items) @@ -321,6 +403,24 @@ public sealed class SetReleaseSession insert.Apply(); } + if (_renest != null) + { + try + { + result.Renest = await _renest(nest, cancellationToken) + ?? throw new InvalidOperationException("The renest step returned nothing."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The nest is still released; the operator runs Auto Nest on it. + nest.Plates.Clear(); + nest.UpdateDrawingQuantities(); + result.Renest = new SetReleaseRenest(false, $"Auto Nest failed: {ex.Message}"); + } + if (result.Renest.Note.Length > 0) + warnings.Add(result.Renest.Note); + } + result.Nest = nest; result.Warnings = warnings; return true; @@ -330,6 +430,8 @@ public sealed class SetReleaseSession // other client can hold, so a nest stored before its reply was lost is still reported. private async Task UploadAsync(SetReleaseGroupResult result) { + if (result.MergedInto is { } draft) + return await SaveMergeAsync(result, draft); var nest = result.Nest!; var id = Guid.NewGuid(); Exception failure; @@ -365,6 +467,50 @@ public sealed class SetReleaseSession return false; } + // True when the merged draft is saved. The save is guarded by the revision the draft was + // chosen at; a failed one counts as saved only when the server holds exactly these bytes. + private async Task SaveMergeAsync(SetReleaseGroupResult result, NestRecord draft) + { + var nest = result.Nest!; + var archive = await Task.Run(() => _writeNest(nest)); + Exception failure; + try + { + var saved = await _nests.UpdateFileAsync(draft.Id, archive, NestRecordFactory.FromNest(nest, draft.Id, archive.LongLength), + draft.Revision, CancellationToken.None); + if (saved.Id == draft.Id && saved.Revision > draft.Revision) + return Created(result, saved); + failure = new IOException($"The nest server did not save a new revision of '{draft.Name}'."); + } + catch (Exception ex) + { + failure = ex; + } + + string detail; + try + { + var current = await _nests.GetMetadataAsync(draft.Id, CancellationToken.None); + if (current is { } found && found.Revision > draft.Revision + && await _nests.GetFileAsync(draft.Id, CancellationToken.None) is { } stored && stored.AsSpan().SequenceEqual(archive)) + return Created(result, found); + detail = current == null + ? "It was deleted." + : current.Revision != draft.Revision + ? $"Someone saved it since you chose it (now revision {current.Revision}); it was left as they saved it." + : "It was not changed."; + } + catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException + or OperationCanceledException) + { + detail = $"It may have been saved; check its history before releasing again."; + } + + result.Outcome = SetReleaseGroupOutcome.Failed; + result.Error = $"Could not merge into draft '{draft.Name}': {failure.Message} {detail}"; + return false; + } + private async Task PostPendingAsync(CancellationToken cancellationToken) { var release = _pendingRelease!; diff --git a/OpenNest.Data/SetReleaseText.cs b/OpenNest.Data/SetReleaseText.cs index 463a73ff..d95e4ccd 100644 --- a/OpenNest.Data/SetReleaseText.cs +++ b/OpenNest.Data/SetReleaseText.cs @@ -54,6 +54,27 @@ public static class SetReleaseText return deleted == 0 ? text : $"{text}, {deleted} deleted"; } + /// + /// Why an undo was refused, for the operator: the release was undone before, or the nests + /// that changed since it, each with what happened to it, so they can be skipped. + /// + public static string UndoRefusal(SetReleaseUndoConflict conflict) + { + ArgumentNullException.ThrowIfNull(conflict); + if (conflict.Reason == SetReleaseUndoRefusal.AlreadyUndone) + return "This release was already undone."; + var lines = conflict.ChangedNests.Select(n => + { + var name = string.IsNullOrWhiteSpace(n.Name) ? n.Id.ToString() : n.Name; + return n.Deleted + ? $"- {name}: deleted since the release" + : $"- {name}: saved since the release (now revision {n.Revision}, {NestStatuses.DisplayName(n.Status)})"; + }); + return "These nests changed after the release, so undoing would overwrite that work:\n" + + string.Join("\n", lines) + + "\n\nUndo the rest and leave these as they are, or restore them from their history."; + } + /// The state of one nest of a release: its status name, or "Deleted". public static string NestState(SetReleaseNest nest) { diff --git a/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs index e6ef9409..f3c9e24d 100644 --- a/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs +++ b/OpenNest.Server.Tests/SetReleaseEndToEndTests.cs @@ -124,4 +124,59 @@ public sealed class SetReleaseEndToEndTests foreach (var group in result.Groups) Assert.Null(await nests.GetMetadataAsync(group.Record!.Id)); } + + [Fact] + public async Task Release_OfASecondKit_MergesIntoTheFirstKitsDraft_AndUndoRestoresIt() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var url = client.BaseAddress!.ToString(); + using var library = new RemoteDrawingLibrary(client, url); + using var sets = new RemoteDrawingSetStore(client, url); + using var nests = new RemoteNestRepository(client, url); + var a = await Store(library, "SYN-A", 4, "Synthetic Steel", CutType.Laser); + var b = await Store(library, "SYN-B", 5, "Synthetic Steel", CutType.Laser); + DrawingSetRecord Kit(string name, params (DrawingLibraryRecord Drawing, int Quantity)[] members) => new() + { + Id = Guid.NewGuid(), + Name = name, + Members = members.Select(m => new DrawingSetMember { DrawingId = m.Drawing.Id, Quantity = m.Quantity }).ToList(), + }; + var firstKit = await sets.GetAsync((await sets.CreateAsync(Kit("First kit", (a, 2)))).Id); + var secondKit = await sets.GetAsync((await sets.CreateAsync(Kit("Second kit", (a, 1), (b, 3)))).Id); + var number = 0; + SetReleaseSession Session() => new(sets, library, nests, url, + bytes => + { + var contents = DrawingPackage.Read(bytes); + return new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings); + }, + Write, + (group, _) => Task.FromResult(new Nest($"Synthetic merge {++number}") { Units = group.Units }), + Read); + + var firstPlan = SetReleasePlan.Build(firstKit!, 1, includeArchived: false); + var first = await Session().RunAsync(firstPlan, firstPlan.Groups, "Synthetic operator", ""); + var draft = (await SetReleasePlan.FindDraftsAsync(nests, firstPlan.Groups[0])).Single(); + Assert.Equal(first.Created.Single().Record!.Id, draft.Id); + + var secondPlan = SetReleasePlan.Build(secondKit!, 2, includeArchived: false); + var second = await Session().RunAsync(secondPlan, secondPlan.Groups, "Synthetic operator", "", + mergeInto: new Dictionary { [secondPlan.Groups[0]] = draft }); + + Assert.Equal("", second.ReleaseError); + var merged = Read((await nests.GetFileAsync(draft.Id))!); + Assert.Equal(new[] { ("SYN-A", 4), ("SYN-B", 6) }, + merged.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))); + var mergeRelease = second.Release!; + Assert.Equal((SetReleaseNestKind.Merged, (int?)draft.Revision, draft.Revision + 1), + (mergeRelease.Nests[0].Kind, mergeRelease.Nests[0].RevisionBefore, mergeRelease.Nests[0].RevisionAfter)); + Assert.Equal(2, (await sets.QueryReleasesAsync(nestId: draft.Id)).Total); + + // Undo the merge: the draft is the first kit's again. + await sets.UndoReleaseAsync(mergeRelease.Id, new SetReleaseUndoRequest { UndoneBy = "Synthetic operator" }); + var restored = Read((await nests.GetFileAsync(draft.Id))!); + Assert.Equal(new[] { ("SYN-A", 2) }, restored.Drawings.Select(d => (d.Name, d.Quantity.Required))); + Assert.Equal(draft.Revision, (await nests.GetMetadataAsync(draft.Id))!.RestoredFromRevision); + } } diff --git a/OpenNest.Tests/Data/SetReleasePlanTests.cs b/OpenNest.Tests/Data/SetReleasePlanTests.cs index 3ff41666..4b98e974 100644 --- a/OpenNest.Tests/Data/SetReleasePlanTests.cs +++ b/OpenNest.Tests/Data/SetReleasePlanTests.cs @@ -72,7 +72,44 @@ public class SetReleasePlanTests var group = Assert.Single(plan.Groups); Assert.Equal(new[] { 12, 4 }, group.Items.Select(i => i.Quantity)); Assert.Equal(16, group.TotalQuantity); - Assert.Equal(4, plan.Multiplier); + Assert.Equal(4, plan.Lines.Single().Quantity); + } + + [Fact] + public void Build_OfSeveralSets_SumsASharedDrawing_KeepingEachSetsShare_AndGroupsAcrossSets() + { + var shared = Drawing("SYN-SHARED"); + var onlyFirst = Drawing("SYN-FIRST"); + var onlySecond = Drawing("SYN-SECOND", material: "Synthetic Aluminum"); + var newer = Drawing("SYN-SHARED"); + newer.Id = shared.Id; + newer.Revision = 3; + var first = Set((shared, 2), (onlyFirst, 1)); + var second = Set((onlySecond, 5), (newer, 1)); + second.Customer = "Another customer"; + + var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(first, 3), new SetReleasePlanLine(second, 2) }, false); + + Assert.Equal(new[] { first.Id, second.Id }, plan.Lines.Select(l => l.Set.Id)); + Assert.Equal("", plan.Customer); + var steel = plan.Groups.Single(g => g.MaterialName == "Synthetic Steel"); + Assert.Equal(new[] { "SYN-SHARED", "SYN-FIRST" }, steel.Items.Select(i => i.Drawing.Name)); + var item = steel.Items[0]; + Assert.Equal(8, item.Quantity); + Assert.Equal(new[] { new SetReleaseItemSource(first.Id, 6), new SetReleaseItemSource(second.Id, 2) }, item.Sources); + Assert.Equal(3, item.Drawing.Revision); + Assert.Equal(10, plan.Groups.Single(g => g.MaterialName == "Synthetic Aluminum").TotalQuantity); + Assert.True(steel.CanRelease); + Assert.Equal("Synthetic set customer", SetReleasePlan.Build(Set((shared, 1)), 1, false).Customer); + } + + [Fact] + public void Build_RefusesTheSameSetTwice_AndNoSets() + { + var set = Set((Drawing("SYN-A"), 1)); + + Assert.Throws(() => SetReleasePlan.Build(new[] { new SetReleasePlanLine(set, 1), new SetReleasePlanLine(set, 2) }, false)); + Assert.Throws(() => SetReleasePlan.Build(Array.Empty(), false)); } [Theory] diff --git a/OpenNest.Tests/Data/SetReleaseSessionTests.cs b/OpenNest.Tests/Data/SetReleaseSessionTests.cs index b58822e7..e5f67dc8 100644 --- a/OpenNest.Tests/Data/SetReleaseSessionTests.cs +++ b/OpenNest.Tests/Data/SetReleaseSessionTests.cs @@ -27,9 +27,37 @@ public class SetReleaseSessionTests return record; } - private SetReleaseSession Session(Func>? createNest = null) => + private SetReleaseSession Session(Func>? createNest = null, + Func>? renest = null) => new(_sets, _library, _nests, Server + "/", ReadPackage, nest => Encoding.UTF8.GetBytes("nest " + nest.Name), - createNest ?? CreateNest); + createNest ?? CreateNest, bytes => _nests.Archives[Encoding.UTF8.GetString(bytes)], renest); + + // A draft on the fake server holding `existing` (copied in from the library) on one placed sheet. + private (NestRecord Record, Nest Nest) Draft(DrawingLibraryRecord existing, int quantity = 2, int revision = 3) + { + var nest = new Nest("Draft 7") + { + Units = Units.Inches, + Material = new Material("Synthetic Steel", "Grade A", 0.28), + Thickness = 0.25, + Status = NestStatus.Draft, + }; + var package = ReadPackage(_library.Packages[(existing.Id, existing.Revision)].Bytes); + LibraryDrawingInsert.Plan(nest, Server, existing, package, quantity).Apply(); + nest.CreatePlate().Parts.Add(new Part(nest.Drawings.Single())); + var record = new NestRecord + { + Id = Guid.NewGuid(), + Revision = revision, + Name = nest.Name, + Material = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + Status = NestStatus.Draft, + }; + _nests.AddDraft(record, nest); + return (record, nest); + } private Task CreateNest(SetReleaseGroup group, CancellationToken cancellationToken) { @@ -315,6 +343,138 @@ public class SetReleaseSessionTests await Assert.ThrowsAsync(() => session.RunAsync(plan, releasable, "", "")); } + [Fact] + public async Task Run_OfTwoSets_MergesIntoADraft_ClearsItsSheets_NestsIt_AndRecordsTheMerge() + { + var a = Stored("SYN-A"); + var b = Stored("SYN-B"); + var first = Set((a, 1), (b, 1)); + var second = Set((a, 2)); + var (draft, _) = Draft(a); + var plan = SetReleasePlan.Build(new[] { new SetReleasePlanLine(first, 2), new SetReleasePlanLine(second, 1) }, false); + var group = Assert.Single(plan.Groups); + var platesSeen = -1; + var session = Session(renest: (nest, _) => + { + platesSeen = nest.Plates.Count; + nest.CreatePlate().Parts.Add(new Part(nest.Drawings.First())); + return Task.FromResult(new SetReleaseRenest(true, "2 copies did not fit.")); + }); + + var result = await session.RunAsync(plan, plan.Groups, "Synthetic operator", "", mergeInto: new Dictionary { [group] = draft }); + + Assert.Equal("", result.ReleaseError); + var merged = Assert.Single(result.Created); + Assert.Same(draft, merged.MergedInto); + Assert.Equal((draft.Id, 4), (merged.Record!.Id, merged.Record.Revision)); + Assert.Empty(_created); + Assert.Empty(_nests.Uploaded); + Assert.Equal(new[] { (draft.Id, 3) }, _nests.Merges.Select(m => (m.Id, m.ExpectedRevision))); + Assert.Equal(0, platesSeen); + var nestAfter = merged.Nest!; + Assert.Equal(new[] { ("SYN-A", 6), ("SYN-B", 2) }, nestAfter.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))); + Assert.Single(nestAfter.Plates); + Assert.Equal((true, "2 copies did not fit."), (merged.Renest!.Nested, merged.Renest.Note)); + Assert.Contains("2 copies did not fit.", merged.Warnings); + + var release = Assert.Single(_sets.Posted); + Assert.Equal(new[] { (first.Id, 2), (second.Id, 1) }, release.Sets.Select(l => (l.SetId, l.Quantity))); + var nestLine = Assert.Single(release.Nests); + Assert.Equal((SetReleaseNestKind.Merged, (int?)3, 4), (nestLine.Kind, nestLine.RevisionBefore, nestLine.RevisionAfter)); + Assert.Equal(new[] { (first.Id, a.Id, 2), (second.Id, a.Id, 2), (first.Id, b.Id, 2) }, + nestLine.Items.Select(i => (i.SetId, i.DrawingId, i.Quantity))); + } + + [Fact] + public async Task Run_MergeIntoADraftWithAnotherRevisionOfADrawing_IsRefusedBeforeAnythingIsSaved() + { + var older = Stored("SYN-A"); + var (draft, _) = Draft(older); + var newer = Stored("SYN-A"); + newer.Id = older.Id; + newer.Revision = older.Revision + 1; + _library.Packages[(newer.Id, newer.Revision)] = (Encoding.UTF8.GetBytes($"package {newer.Id} r{newer.Revision}"), Units.Inches); + newer.PackageSha256 = Convert.ToHexString(SHA256.HashData(_library.Packages[(newer.Id, newer.Revision)].Bytes)).ToLowerInvariant(); + var plan = SetReleasePlan.Build(Set((newer, 1), (Stored("SYN-NEW"), 1)), 1, false); + + var result = await Session().RunAsync(plan, plan.Groups, "", "", + mergeInto: new Dictionary { [plan.Groups[0]] = draft }); + + var group = Assert.Single(result.Groups); + Assert.Equal(SetReleaseGroupOutcome.Refused, group.Outcome); + Assert.Contains("Update Drawings", group.Error); + Assert.Empty(_nests.Merges); + Assert.Empty(_sets.Posted); + } + + [Fact] + public async Task Run_MergeIntoADraftSavedMeanwhile_FailsThatGroup_AndRecordsNoRelease() + { + var a = Stored("SYN-A"); + var (draft, nest) = Draft(a); + var plan = SetReleasePlan.Build(Set((a, 1)), 1, false); + _nests.SaveBeforeNextMerge = true; + + var result = await Session().RunAsync(plan, plan.Groups, "", "", + mergeInto: new Dictionary { [plan.Groups[0]] = draft }); + + var group = Assert.Single(result.Groups); + Assert.Equal(SetReleaseGroupOutcome.Failed, group.Outcome); + Assert.Contains("Someone saved it since you chose it (now revision 4)", group.Error); + Assert.Empty(_sets.Posted); + Assert.Null(result.Release); + } + + [Fact] + public async Task Run_MergeChecks_RefuseAWrongTargetBeforeStarting() + { + var a = Stored("SYN-A"); + var (draft, _) = Draft(a); + var plan = SetReleasePlan.Build(Set((a, 1), (Stored("SYN-B", material: "Synthetic Aluminum"), 1)), 1, false); + var noReader = new SetReleaseSession(_sets, _library, _nests, Server, ReadPackage, n => Array.Empty(), CreateNest); + + await Assert.ThrowsAsync(() => Session().RunAsync(plan, new[] { plan.Groups[0] }, "", "", + mergeInto: new Dictionary { [plan.Groups[1]] = draft })); + await Assert.ThrowsAsync(() => Session().RunAsync(plan, plan.Groups, "", "", + mergeInto: new Dictionary { [plan.Groups[0]] = draft, [plan.Groups[1]] = draft })); + await Assert.ThrowsAsync(() => noReader.RunAsync(plan, plan.Groups, "", "", + mergeInto: new Dictionary { [plan.Groups[0]] = draft })); + // A draft of the aluminum group's material is refused once downloaded. + var wrong = await Session().RunAsync(plan, plan.Groups, "", "", + mergeInto: new Dictionary { [plan.Groups.Single(g => g.MaterialName == "Synthetic Aluminum")] = draft }); + Assert.Contains(wrong.Groups, g => g.Outcome == SetReleaseGroupOutcome.Refused && g.Error.Contains("is not Synthetic Aluminum")); + Assert.Empty(_nests.Merges); + } + + [Fact] + public async Task Run_WhenNestingAfterReleaseFails_StillReleasesTheNestUnnested() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + var session = Session(renest: (nest, _) => + { + nest.CreatePlate(); + throw new InvalidOperationException("Synthetic engine failure."); + }); + + var result = await session.RunAsync(plan, plan.Groups, "", ""); + + var created = Assert.Single(result.Created); + Assert.Equal((false, "Auto Nest failed: Synthetic engine failure."), (created.Renest!.Nested, created.Renest.Note)); + Assert.Empty(created.Nest!.Plates); + Assert.Single(_sets.Posted); + } + + [Fact] + public async Task FindDrafts_AsksForDraftsOfTheGroup() + { + var plan = SetReleasePlan.Build(Set((Stored("SYN-A"), 1)), 1, false); + + await SetReleasePlan.FindDraftsAsync(_nests, plan.Groups[0]); + + var query = Assert.Single(_nests.Queries); + Assert.Equal((NestStatus.Draft, "Synthetic Steel", "Grade A", 0.25), (query.Status, query.Material, query.MaterialGrade, query.Thickness)); + } + private sealed class RecordingProgress(Action? onReport = null) : IProgress { public List Reports { get; } = new(); @@ -381,9 +541,53 @@ public class SetReleaseSessionTests return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null); } + /// Nests a test reader returns, keyed by their (fake) archive text. + public Dictionary Archives { get; } = new(); + private readonly Dictionary<(Guid, int), byte[]> _revisions = new(); + public List<(Guid Id, int ExpectedRevision)> Merges { get; } = new(); + public List Queries { get; } = new(); + public bool SaveBeforeNextMerge { get; set; } + + public void AddDraft(NestRecord record, Nest nest) + { + var key = $"draft {record.Id} r{record.Revision}"; + Archives[key] = nest; + _revisions[(record.Id, record.Revision)] = Encoding.UTF8.GetBytes(key); + _stored[record.Id] = record; + } + + public Task GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) => + Task.FromResult(_revisions.TryGetValue((id, revisionNumber), out var bytes) ? bytes : null); + + public Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + var current = _stored[id]; + if (SaveBeforeNextMerge) + { + SaveBeforeNextMerge = false; + current = _stored[id] = new NestRecord { Id = id, Name = current.Name, Revision = current.Revision + 1, Status = NestStatus.Draft }; + _revisions[(id, current.Revision)] = Encoding.UTF8.GetBytes("someone else's save"); + } + if (current.Revision != expectedRevision) + throw new IOException("Could not update the nest: HTTP 409 Conflict."); + Merges.Add((id, expectedRevision)); + var saved = new NestRecord { Id = id, Name = record.Name, Revision = current.Revision + 1, Status = record.Status }; + _stored[id] = saved; + _revisions[(id, saved.Revision)] = nestFile; + return Task.FromResult(saved); + } + + public Task QueryAsync(NestQuery query, CancellationToken ct = default) + { + Queries.Add(query); + return Task.FromResult(new NestPage { Items = Array.Empty(), Limit = query.Limit }); + } + + public Task GetFileAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(_stored.TryGetValue(id, out var record) && _revisions.TryGetValue((id, record.Revision), out var bytes) ? bytes : null); + public Task> ListAsync(CancellationToken ct = default) => throw new NotSupportedException(); - public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException(); - public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException(); public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); diff --git a/OpenNest.Tests/Data/SetReleaseTextTests.cs b/OpenNest.Tests/Data/SetReleaseTextTests.cs index 04d953e8..cc0bf4d4 100644 --- a/OpenNest.Tests/Data/SetReleaseTextTests.cs +++ b/OpenNest.Tests/Data/SetReleaseTextTests.cs @@ -53,6 +53,27 @@ public class SetReleaseTextTests Assert.Equal("Released from set 'Synthetic kit' revision 3 ×2 by Synthetic operator", text); } + [Fact] + public void UndoRefusal_NamesEachChangedNest_OrSaysItWasUndoneBefore() + { + var conflict = new SetReleaseUndoConflict + { + Reason = SetReleaseUndoRefusal.NestsChanged, + ChangedNests = new[] + { + new NestRecord { Name = "Edited nest", Revision = 5, Status = NestStatus.ToBeCut }, + new NestRecord { Name = "Removed nest", Revision = 2, Deleted = true }, + }, + }; + + var text = SetReleaseText.UndoRefusal(conflict); + + Assert.Contains("- Edited nest: saved since the release (now revision 5, To Be Cut)", text); + Assert.Contains("- Removed nest: deleted since the release", text); + Assert.Equal("This release was already undone.", + SetReleaseText.UndoRefusal(new SetReleaseUndoConflict { Reason = SetReleaseUndoRefusal.AlreadyUndone })); + } + [Fact] public void Origin_OfAMergeOfSeveralSets_NamesThemAll_AndAnUndo() { diff --git a/OpenNest/Forms/ReleaseSetForm.cs b/OpenNest/Forms/ReleaseSetForm.cs index 4fd87dec..5ea6b290 100644 --- a/OpenNest/Forms/ReleaseSetForm.cs +++ b/OpenNest/Forms/ReleaseSetForm.cs @@ -141,7 +141,7 @@ namespace OpenNest.Forms var chosen = ChosenGroups(); if (chosen.Count == 0) return; - var multiplier = plan.Multiplier; + var multiplier = plan.Lines[0].Quantity; if (ShowMessage($"Create {chosen.Count} Draft nest{(chosen.Count == 1 ? "" : "s")} from set '{set.Name}'" + (multiplier == 1 ? "" : $" ×{multiplier}") + "? Each opens in its own tab.", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK) diff --git a/docs/nest-storage.md b/docs/nest-storage.md index 5526f62a..cc65eda4 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -539,37 +539,63 @@ checks `DrawingSetRules`/`SetReleaseRules` before sending, raises refused, and refuses replies whose id, revision, members, sets or nests differ from what was sent. -`SetReleasePlan.Build(set, multiplier, includeArchived)` splits a set read from -the server into groups without any I/O. A group is the members whose drawings -share material name and grade (by `SharedListNames.Key`), thickness (rounded to -4 decimals), units and cut type; quantities are multiplied. Archived drawings -are skipped unless included. A group is refused when two of its drawings share a +`SetReleasePlan.Build(lines, includeArchived)` splits one or more sets read from +the server, each with a number of kits (`SetReleasePlanLine`; `Build(set, +multiplier, includeArchived)` is the one-set form), into groups without any I/O. +A drawing listed by several sets becomes one item with the quantities summed and +each set's share kept (`Sources`), using the newest revision the sets were read +with. A group is the items whose drawings share material name and grade (by +`SharedListNames.Key`), thickness (rounded to 4 decimals), units and cut type. +The plan's customer is the one every set names, or blank. A set listed twice is +refused (give it more kits instead). Archived drawings are skipped unless included. A group is refused when two of its drawings share a name (compared ordinally, as a nest keys drawings), and every drawing without a material or a positive thickness goes to one refused "unassigned" group, listed last. -`SetReleaseSession.RunAsync` releases the chosen releasable groups in plan order: +`SetReleasePlan.FindDraftsAsync(nests, group)` lists the Draft nests a group +could merge into, using the nest query's status, material, grade and thickness +filters. Units and cut type are not in nest metadata, so they are checked on the +downloaded nest. -1. **Build every group first.** For each group the caller's factory makes an +`SetReleaseSession.RunAsync` releases the chosen releasable groups in plan order. +A group named in its `mergeInto` map merges into that draft instead of making a +new nest: + +1. **Build every group first.** For a new nest the caller's factory makes an empty nest in the group's units; the session sets material, grade, - thickness, the set's customer (when it has one) and `Draft`, downloads each - member's exact listed revision and inserts it through `LibraryDrawingInsert`. - A refusal (package hash no longer matching its record, a missing revision, a - nest in other units, an insert refusal) or a download failure stops here, so - nothing is uploaded. The factory may already have allocated nest numbers. + thickness, the plan's customer (when there is one) and `Draft`. For a merge + the session downloads the draft at the revision it was chosen at (reading it + with the caller's nest reader) and refuses it unless it is still a Draft of + the group's material, grade, thickness and units with no drawing for another + cut type; then it removes every plate, since a merge renests the whole + demand. Either way it downloads each item's exact revision and inserts it + through `LibraryDrawingInsert` (a drawing the draft already holds at that + revision gains quantity; another revision is refused, pointing at Update + Drawings). When the caller supplies a renest step ("Nest after release"), + every group is then nested from scratch; a step that throws leaves the nest + without plates and a note, and the release goes on. A refusal or a download + failure stops here, so nothing is uploaded. The factory may already have + allocated nest numbers. 2. **Upload in order** under client-allocated ids, never cancelling an upload in flight; cancellation is checked between uploads. A failed upload is checked by - its id, so one stored before its reply was lost still counts as created; the - first real failure stops the rest. -3. **Post the release** only when every chosen group was created, under a - client-allocated id. It names the set once (the multiplier is its quantity), - each nest as created at its uploaded revision, and the drawing revisions and - quantities each nest received. When the post fails, the nests stay and + its id, so one stored before its reply was lost still counts as created. A + merged draft is saved with the revision it was chosen at as `If-Match`; a + failed save counts only when the server holds exactly the bytes sent, and + otherwise says whether someone saved the draft meanwhile. The first real + failure stops the rest. +3. **Post the release** only when every chosen group was created or merged, + under a client-allocated id. It names every set with its kits, each nest as + created or merged with the revisions before and after, and the drawing + revisions and quantities each set put in each nest. When the post fails, the nests stay and `RetryReleaseAsync` posts the same id again; a 409 naming that id is adopted as the stored release. The result lists each group's outcome (`Created`, `Refused`, `Failed`, -`NotStarted`), the created nests' records and in-memory `Nest` objects (so the -desktop can open them without downloading), and the release or why it was not recorded. +`NotStarted`), the created or merged nests' records and in-memory `Nest` objects +(so the desktop can open them without downloading), the draft each merged into, +what nesting after release did, and the release or why it was not recorded. + +`RemoteDrawingSetStore.UndoReleaseAsync` undoes a release (see above); +`SetReleaseText.UndoRefusal` words a refusal for the operator, naming each changed nest. Schema v7 adds `drawing_set_revisions` (every revision's details and archived flag), `drawing_set_members` (each revision's members by position, unique per