feat(data): release several sets in one plan and merge groups into draft nests

The release plan now takes several sets, each with its own kit count. A
drawing listed by more than one set becomes one item with the quantities
summed and each set's share kept, so the release can record what every kit
put into every nest. FindDraftsAsync lists the Draft nests of a group's
material through the new nest query filters.

The session can merge a group into a chosen draft instead of making a new
nest. It downloads the draft at the revision it was chosen at, refuses it
unless it is still a Draft of the group's material, thickness, units and cut
type, adds the items, and removes every plate so the whole demand is nested
again. The save is guarded by that revision, so a draft someone saved
meanwhile fails its group instead of being overwritten. An optional renest
step, run before anything is uploaded, will back "Nest after release"; if it
throws, the nest is still released without a layout.

The release record now carries every set line, merged nests with their
revisions before and after, and each set's drawing quantities.
SetReleaseText.UndoRefusal words a refused undo for the release history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 10:25:53 -04:00
1 parent 4b604d3874
commit 74203483c5
9 files changed
+690 -102

No files matched your search

+120 -42
View File
@@ -3,22 +3,39 @@ using OpenNest;
namespace OpenNest.Data;
/// <summary>One set member as released: its drawing's current record and the multiplied quantity.</summary>
/// <summary>One set of a release plan: a set read from the server and how many kits of it.</summary>
public sealed record SetReleasePlanLine(DrawingSetRecord Set, int Quantity);
/// <summary>How many of an item one set of the release asked for.</summary>
public sealed record SetReleaseItemSource(Guid SetId, int Quantity);
/// <summary>
/// One library drawing as released: its current record and the quantity summed over every
/// set of the release that lists it, kits included.
/// </summary>
public sealed class SetReleaseItem
{
internal SetReleaseItem(DrawingSetMember member, int quantity)
internal SetReleaseItem(DrawingSetMember member, DrawingLibraryRecord drawing, IReadOnlyList<SetReleaseItemSource> sources)
{
Member = member;
Drawing = member.Drawing!;
Quantity = quantity;
Drawing = drawing;
Sources = sources;
Quantity = checked(sources.Sum(s => s.Quantity));
}
/// <summary>The member that first listed the drawing, in set order.</summary>
public DrawingSetMember Member { get; }
/// <summary>The drawing's current library record, as joined by the server when the set was read.</summary>
/// <summary>
/// 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.
/// </summary>
public DrawingLibraryRecord Drawing { get; }
/// <summary>The member quantity times the release multiplier.</summary>
/// <summary>Each set's share: its member quantity times its kits, in set order.</summary>
public IReadOnlyList<SetReleaseItemSource> Sources { get; }
/// <summary>The total over <see cref="Sources"/>.</summary>
public int Quantity { get; }
/// <summary>The drawing is archived and was ticked back in.</summary>
@@ -83,29 +100,32 @@ public sealed class SetReleaseGroup
}
/// <summary>
/// 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 <see cref="SharedListNames.Key"/>), 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 <see cref="SharedListNames.Key"/>),
/// 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).
/// </summary>
public sealed class SetReleasePlan
{
private SetReleasePlan(DrawingSetRecord set, int multiplier, bool includeArchived,
private SetReleasePlan(IReadOnlyList<SetReleasePlanLine> lines, bool includeArchived,
IReadOnlyList<SetReleaseGroup> groups, IReadOnlyList<DrawingSetMember> 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] : "";
}
/// <summary>The set revision being released, as read from the server.</summary>
public DrawingSetRecord Set { get; }
/// <summary>The sets released and how many kits of each, as read from the server.</summary>
public IReadOnlyList<SetReleasePlanLine> Lines { get; }
public int Multiplier { get; }
/// <summary>The customer every set names, or blank when they differ.</summary>
public string Customer { get; }
public bool IncludeArchived { get; }
@@ -119,33 +139,68 @@ public sealed class SetReleasePlan
public IReadOnlyList<DrawingSetMember> SkippedArchived { get; }
/// <param name="set">A set read from the server, so every member carries its drawing's current record.</param>
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);
/// <param name="lines">Sets read from the server, so every member carries its drawing's current record.</param>
public static SetReleasePlan Build(IReadOnlyList<SetReleasePlanLine> 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<DrawingSetMember>();
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<Guid>();
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<DrawingSetMember>();
var order = new List<Guid>();
var entries = new Dictionary<Guid, (DrawingSetMember Member, DrawingLibraryRecord Drawing, List<SetReleaseItemSource> 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<SetReleaseItemSource> { 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);
}
/// <summary>
/// Draft nests on the server that <paramref name="group"/> 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.
/// </summary>
public static async Task<IReadOnlyList<NestRecord>> FindDraftsAsync(INestRepository nests, SetReleaseGroup group,
int limit = 50, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nests);
ArgumentNullException.ThrowIfNull(group);
if (!group.CanRelease)
return Array.Empty<NestRecord>();
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;
}
/// <summary>The group key of a drawing that has a material and thickness.</summary>
+180 -34
View File
@@ -4,7 +4,10 @@ namespace OpenNest.Data;
public enum SetReleaseGroupOutcome
{
/// <summary>The group's nest is on the server; <see cref="SetReleaseGroupResult.Record"/> is its record.</summary>
/// <summary>
/// The group's nest is on the server, new or merged; <see cref="SetReleaseGroupResult.Record"/>
/// is its record.
/// </summary>
Created,
/// <summary>The group could not be built; nothing was uploaded for any group.</summary>
@@ -35,9 +38,15 @@ public sealed class SetReleaseGroupResult
/// </summary>
public Nest? Nest { get; internal set; }
/// <summary>The server record of a created nest: its id and revision.</summary>
/// <summary>The server record of a created or merged nest: its id and the revision the release wrote.</summary>
public NestRecord? Record { get; internal set; }
/// <summary>The draft the group merged into, as chosen (its revision before the merge); null for a new nest.</summary>
public NestRecord? MergedInto { get; internal set; }
/// <summary>What nesting after release did; null when it was not asked for.</summary>
public SetReleaseRenest? Renest { get; internal set; }
/// <summary>Why the group was refused or failed; empty otherwise.</summary>
public string Error { get; internal set; } = "";
@@ -45,12 +54,21 @@ public sealed class SetReleaseGroupResult
public IReadOnlyList<string> Warnings { get; internal set; } = Array.Empty<string>();
}
/// <summary>
/// What nesting a group after release did: <see cref="Nested"/> when a layout was kept,
/// and an operator-facing note either way (unplaced parts, or why nothing was kept).
/// </summary>
public sealed record SetReleaseRenest(bool Nested, string Note);
public enum SetReleaseStage
{
/// <summary>Downloading the group's drawings and building its nest; nothing is uploaded yet.</summary>
/// <summary>
/// Downloading the group's drawings (and draft), building its nest and nesting it when
/// asked; nothing is uploaded yet.
/// </summary>
Preparing,
/// <summary>Uploading the group's nest.</summary>
/// <summary>Uploading the group's nest, or saving the draft it merged into.</summary>
Uploading,
/// <summary>Every nest is uploaded; the release record is being posted.</summary>
@@ -115,10 +133,16 @@ public sealed class SetReleaseResult
}
/// <summary>
/// Releases a drawing set: one new nest per chosen group of a <see cref="SetReleasePlan"/>,
/// each built through <see cref="LibraryDrawingInsert"/> from the members' exact library
/// revisions, uploaded as a <see cref="NestStatus.Draft"/>, and only then recorded as one
/// release (S7).
/// Releases one or more drawing sets: one nest per chosen group of a <see cref="SetReleasePlan"/>,
/// either new (uploaded as a <see cref="NestStatus.Draft"/>) or an existing draft of the same
/// group that the release merges into, each built through <see cref="LibraryDrawingInsert"/>
/// from the items' exact library revisions, and only then recorded as one release (S7).
/// <para>
/// 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.
/// </para>
/// <para>
/// 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<byte[], LibraryPackage> _readPackage;
private readonly Func<Nest, byte[]> _writeNest;
private readonly Func<SetReleaseGroup, CancellationToken, Task<Nest>> _createNest;
private readonly Func<byte[], Nest>? _readNest;
private readonly Func<Nest, CancellationToken, Task<SetReleaseRenest>>? _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.
/// </param>
/// <param name="readNest">Reads a downloaded nest archive (the desktop uses <c>NestReader</c>); required to merge.</param>
/// <param name="renest">
/// 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.
/// </param>
public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl,
Func<byte[], LibraryPackage> readPackage, Func<Nest, byte[]> writeNest,
Func<SetReleaseGroup, CancellationToken, Task<Nest>> createNest)
Func<SetReleaseGroup, CancellationToken, Task<Nest>> createNest,
Func<byte[], Nest>? readNest = null, Func<Nest, CancellationToken, Task<SetReleaseRenest>>? 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
/// <summary>Every nest was created but the release was not recorded; <see cref="RetryReleaseAsync"/> may post it.</summary>
public bool HasPendingRelease => _pendingRelease != null;
/// <summary>Releases <paramref name="groups"/>, all from <paramref name="plan"/>. Call once per session.</summary>
/// <summary>
/// Releases <paramref name="groups"/>, all from <paramref name="plan"/>. A group named in
/// <paramref name="mergeInto"/> merges into that draft (as read, at that revision) instead
/// of making a new nest. Call once per session.
/// </summary>
public async Task<SetReleaseResult> RunAsync(SetReleasePlan plan, IReadOnlyCollection<SetReleaseGroup> groups,
string releasedBy, string notes, IProgress<SetReleaseProgress>? progress = null,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default, IReadOnlyDictionary<SetReleaseGroup, NestRecord>? mergeInto = null)
{
mergeInto ??= new Dictionary<SetReleaseGroup, NestRecord>();
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<bool> 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<bool> 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<bool> 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<SetReleaseRecord> PostPendingAsync(CancellationToken cancellationToken)
{
var release = _pendingRelease!;
+21
View File
@@ -54,6 +54,27 @@ public static class SetReleaseText
return deleted == 0 ? text : $"{text}, {deleted} deleted";
}
/// <summary>
/// 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.
/// </summary>
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.";
}
/// <summary>The state of one nest of a release: its status name, or "Deleted".</summary>
public static string NestState(SetReleaseNest nest)
{
@@ -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<SetReleaseGroup, NestRecord> { [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);
}
}
+38 -1
View File
@@ -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<ArgumentException>(() => SetReleasePlan.Build(new[] { new SetReleasePlanLine(set, 1), new SetReleasePlanLine(set, 2) }, false));
Assert.Throws<ArgumentException>(() => SetReleasePlan.Build(Array.Empty<SetReleasePlanLine>(), false));
}
[Theory]
+208 -4
View File
@@ -27,9 +27,37 @@ public class SetReleaseSessionTests
return record;
}
private SetReleaseSession Session(Func<SetReleaseGroup, CancellationToken, Task<Nest>>? createNest = null) =>
private SetReleaseSession Session(Func<SetReleaseGroup, CancellationToken, Task<Nest>>? createNest = null,
Func<Nest, CancellationToken, Task<SetReleaseRenest>>? 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<Nest> CreateNest(SetReleaseGroup group, CancellationToken cancellationToken)
{
@@ -315,6 +343,138 @@ public class SetReleaseSessionTests
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<SetReleaseGroup, NestRecord> { [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<SetReleaseGroup, NestRecord> { [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<SetReleaseGroup, NestRecord> { [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<byte>(), CreateNest);
await Assert.ThrowsAsync<ArgumentException>(() => Session().RunAsync(plan, new[] { plan.Groups[0] }, "", "",
mergeInto: new Dictionary<SetReleaseGroup, NestRecord> { [plan.Groups[1]] = draft }));
await Assert.ThrowsAsync<ArgumentException>(() => Session().RunAsync(plan, plan.Groups, "", "",
mergeInto: new Dictionary<SetReleaseGroup, NestRecord> { [plan.Groups[0]] = draft, [plan.Groups[1]] = draft }));
await Assert.ThrowsAsync<InvalidOperationException>(() => noReader.RunAsync(plan, plan.Groups, "", "",
mergeInto: new Dictionary<SetReleaseGroup, NestRecord> { [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<SetReleaseGroup, NestRecord> { [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<SetReleaseProgress>? onReport = null) : IProgress<SetReleaseProgress>
{
public List<SetReleaseProgress> Reports { get; } = new();
@@ -381,9 +541,53 @@ public class SetReleaseSessionTests
return Task.FromResult(_stored.TryGetValue(id, out var record) ? record : null);
}
/// <summary>Nests a test reader returns, keyed by their (fake) archive text.</summary>
public Dictionary<string, Nest> Archives { get; } = new();
private readonly Dictionary<(Guid, int), byte[]> _revisions = new();
public List<(Guid Id, int ExpectedRevision)> Merges { get; } = new();
public List<NestQuery> 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<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) =>
Task.FromResult(_revisions.TryGetValue((id, revisionNumber), out var bytes) ? bytes : null);
public Task<NestRecord> 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<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default)
{
Queries.Add(query);
return Task.FromResult(new NestPage { Items = Array.Empty<NestRecord>(), Limit = query.Limit });
}
public Task<byte[]?> 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<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotSupportedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
@@ -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()
{
+1 -1
View File
@@ -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)
+46 -20
View File
@@ -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