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