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