using OpenNest;
namespace OpenNest.Data;
public enum SetReleaseGroupOutcome
{
///
/// 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.
Refused,
/// Building or uploading the group failed; says how.
Failed,
/// An earlier group stopped the release, or it was canceled, before this group was reached.
NotStarted,
}
/// What happened to one chosen group of a release.
public sealed class SetReleaseGroupResult
{
internal SetReleaseGroupResult(SetReleaseGroup group)
{
Group = group;
}
public SetReleaseGroup Group { get; }
public SetReleaseGroupOutcome Outcome { get; internal set; } = SetReleaseGroupOutcome.NotStarted;
///
/// The nest as built, held so a created one can be opened without downloading it again.
/// Set for a created nest and for one whose upload failed; null otherwise.
///
public Nest? Nest { get; internal set; }
/// 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; } = "";
/// The plan's warnings plus any the library inserts raised.
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 draft) and building its nest; nothing is uploaded yet.
Preparing,
/// Nesting the group's built nest after release; nothing is uploaded yet.
Nesting,
/// Uploading the group's nest, or saving the draft it merged into.
Uploading,
/// Every nest is uploaded; the release record is being posted.
Recording,
}
/// Where a release has got to. is null while recording.
public sealed class SetReleaseProgress
{
public SetReleaseProgress(SetReleaseStage stage, int index, int count, SetReleaseGroup? group)
{
Stage = stage;
Index = index;
Count = count;
Group = group;
}
public SetReleaseStage Stage { get; }
/// Zero-based position of among the chosen groups.
public int Index { get; }
public int Count { get; }
public SetReleaseGroup? Group { get; }
}
/// Every chosen group's outcome, and the release record when all of them were created.
public sealed class SetReleaseResult
{
internal SetReleaseResult(IReadOnlyList groups, bool canceled,
SetReleaseRecord? release, string releaseError)
{
Groups = groups;
Canceled = canceled;
Release = release;
ReleaseError = releaseError;
}
/// The chosen groups in plan order.
public IReadOnlyList Groups { get; }
/// The nests now on the server, whether or not the release was recorded.
public IReadOnlyList Created =>
Groups.Where(g => g.Outcome == SetReleaseGroupOutcome.Created).ToList();
/// Cancellation stopped the release between groups.
public bool Canceled { get; }
/// The stored release; null unless every chosen group was created and the post succeeded.
public SetReleaseRecord? Release { get; }
///
/// Why posting the release failed after every nest was created; empty otherwise.
/// posts the same release again.
///
public string ReleaseError { get; }
/// This result once has recorded .
public SetReleaseResult WithRelease(SetReleaseRecord release) =>
new(Groups, Canceled, release ?? throw new ArgumentNullException(nameof(release)), "");
}
///
/// Releases drawing sets and library drawings on their own: 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
/// nothing, and so does a server too old to record drawings released on their own, which is
/// checked first. Uploads then run in order; the first failure stops them, and cancellation is
/// honoured between uploads, never during one. Nests already uploaded stay as ordinary
/// nests and are listed in the result; the release is posted only when every chosen group
/// was created. Nothing is rolled back.
///
///
public sealed class SetReleaseSession
{
private readonly IDrawingSetStore _sets;
private readonly IDrawingLibrary _library;
private readonly INestRepository _nests;
private readonly string _serverUrl;
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;
/// Reads a downloaded library package (the desktop uses DrawingPackage.Read).
/// Serializes a nest archive for upload (the desktop uses NestWriter).
///
/// 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? 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));
_readPackage = readPackage ?? throw new ArgumentNullException(nameof(readPackage));
_writeNest = writeNest ?? throw new ArgumentNullException(nameof(writeNest));
_createNest = createNest ?? throw new ArgumentNullException(nameof(createNest));
if (ServerUrl.Key(serverUrl).Length == 0)
throw new ArgumentException("A set release needs the nest server URL.", nameof(serverUrl));
_serverUrl = serverUrl;
}
/// Every nest was created but the release was not recorded; may post it.
public bool HasPendingRelease => _pendingRelease != null;
///
/// 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, IReadOnlyDictionary? mergeInto = null)
{
mergeInto ??= new Dictionary();
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(groups);
if (groups.Count == 0)
throw new ArgumentException("Choose at least one group to release.", nameof(groups));
if (groups.Any(g => g is null || !plan.Groups.Contains(g)) || groups.Distinct().Count() != groups.Count)
throw new ArgumentException("Every group must come from the plan, once.", nameof(groups));
if (groups.FirstOrDefault(g => !g.CanRelease) is { } refused)
throw new ArgumentException(refused.Refusal, nameof(groups));
var textError = DrawingLibraryRules.TextError("releasedBy", releasedBy ?? "", DrawingLibraryRules.MaxTextLength)
?? 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)
{
MergedInto = mergeInto.TryGetValue(g, out var draft) ? draft : null,
}).ToList();
// A server that cannot record drawings on their own would refuse the release after its
// nests were saved, so it is asked before anything is built.
if (plan.Drawings.Count > 0)
{
try
{
await _sets.EnsureDrawingReleasesSupportedAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return new SetReleaseResult(results, canceled: true, null, "");
}
catch (ServerTooOldException ex)
{
Refuse(results[0], ex.Message);
return new SetReleaseResult(results, canceled: false, null, "");
}
catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
or TimeoutException)
{
results[0].Outcome = SetReleaseGroupOutcome.Failed;
results[0].Error = $"Could not check that the nest server records drawings released on their own: {ex.Message}";
return new SetReleaseResult(results, canceled: false, null, "");
}
}
// Build every nest first: a refusal here leaves nothing on the server.
for (var i = 0; i < results.Count; i++)
{
var result = results[i];
var index = i;
progress?.Report(new SetReleaseProgress(SetReleaseStage.Preparing, i, results.Count, result.Group));
bool ready;
try
{
ready = await PrepareAsync(plan, result, () => progress?.Report(
new SetReleaseProgress(SetReleaseStage.Nesting, index, results.Count, result.Group)), cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: true, null, "");
}
catch (Exception ex) when (ex is IOException or HttpRequestException or InvalidDataException
or System.Text.Json.JsonException or TimeoutException)
{
result.Outcome = SetReleaseGroupOutcome.Failed;
result.Error = $"Could not prepare the {Describe(result.Group)} nest: {ex.Message}";
ready = false;
}
if (!ready)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: false, null, "");
}
}
for (var i = 0; i < results.Count; i++)
{
if (cancellationToken.IsCancellationRequested)
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: true, null, "");
}
progress?.Report(new SetReleaseProgress(SetReleaseStage.Uploading, i, results.Count, results[i].Group));
if (!await UploadAsync(results[i]))
{
DiscardBuilt(results);
return new SetReleaseResult(results, canceled: false, null, "");
}
}
_pendingRelease = new SetReleaseRecord
{
Id = Guid.NewGuid(),
Sets = plan.Lines.Select(l => new SetReleaseLine
{
SetId = l.Set.Id,
SetRevision = l.Set.Revision,
Quantity = l.Quantity,
}).ToList(),
Drawings = plan.Drawings.Select(d => new SetReleaseDrawing
{
DrawingId = d.Drawing.Id,
Quantity = d.Quantity,
}).ToList(),
Notes = notes ?? "",
ReleasedBy = releasedBy ?? "",
Nests = results.Select(r => new SetReleaseNest
{
NestId = r.Record!.Id,
Kind = r.MergedInto is null ? SetReleaseNestKind.Created : SetReleaseNestKind.Merged,
RevisionBefore = r.MergedInto?.Revision,
RevisionAfter = r.Record.Revision,
Items = r.Group.Items.SelectMany(i => i.Sources.Select(source => new SetReleaseNestItem
{
SetId = source.SetId,
DrawingId = i.Drawing.Id,
DrawingRevision = i.Drawing.Revision,
Quantity = source.Quantity,
})).ToList(),
MaterialName = r.Group.MaterialName,
MaterialGrade = r.Group.MaterialGrade,
Thickness = r.Group.Thickness,
Units = r.Group.Units,
CutType = r.Group.CutType,
}).ToList(),
};
progress?.Report(new SetReleaseProgress(SetReleaseStage.Recording, results.Count, results.Count, null));
try
{
return new SetReleaseResult(results, canceled: false, await PostPendingAsync(CancellationToken.None), "");
}
catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
or OperationCanceledException)
{
return new SetReleaseResult(results, canceled: false, null,
$"Every nest was created, but the release was not recorded: {ex.Message}");
}
}
///
/// Posts the release again after , under the
/// same id, so a post that was in fact stored is not recorded twice.
///
public Task RetryReleaseAsync(CancellationToken cancellationToken = default)
{
if (_pendingRelease == null)
throw new InvalidOperationException("There is no unrecorded release to post.");
return PostPendingAsync(cancellationToken);
}
// 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, Action nesting,
CancellationToken cancellationToken)
{
var group = result.Group;
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()}.");
// 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)
{
var record = item.Drawing;
var bytes = await _library.GetRevisionPackageAsync(record.Id, record.Revision, cancellationToken);
if (bytes == null)
return Refuse(result, $"'{record.Name}' revision {record.Revision} is no longer in the library. Reload the set or drawing.");
var package = await Task.Run(() => _readPackage(bytes), cancellationToken);
var insert = LibraryDrawingInsert.Plan(nest, _serverUrl, record, package, item.Quantity);
if (insert.IsRefused)
return Refuse(result, insert.Refusal);
warnings.AddRange(insert.Warnings);
insert.Apply();
}
if (_renest != null)
{
nesting();
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;
}
// True when the nest is on the server. A failed upload is checked by its id, which no
// 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;
try
{
var archive = await Task.Run(() => _writeNest(nest));
var saved = await _nests.UploadAsync(archive, NestRecordFactory.FromNest(nest, id, archive.LongLength),
CancellationToken.None);
if (saved.Id == id)
return Created(result, saved);
failure = new IOException($"The nest server stored it under a different id ({saved.Id}).");
}
catch (Exception ex)
{
failure = ex;
}
string stored;
try
{
if (await _nests.GetMetadataAsync(id, CancellationToken.None) is { } found && found.Id == id)
return Created(result, found);
stored = "It was not saved.";
}
catch (Exception ex) when (ex is IOException or HttpRequestException or System.Text.Json.JsonException
or OperationCanceledException)
{
stored = $"It may have been saved as '{nest.Name}' (id {id}); check Open from Database before releasing again.";
}
result.Outcome = SetReleaseGroupOutcome.Failed;
result.Error = $"Could not upload the {Describe(result.Group)} nest: {failure.Message} {stored}";
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!;
SetReleaseRecord stored;
try
{
stored = await _sets.PostReleaseAsync(release, cancellationToken);
}
catch (SetReleasePostConflictException conflict) when (conflict.Current.Id == release.Id)
{
// An earlier post whose reply was lost was stored.
stored = conflict.Current;
}
_pendingRelease = null;
return stored;
}
private static bool Created(SetReleaseGroupResult result, NestRecord record)
{
result.Outcome = SetReleaseGroupOutcome.Created;
result.Record = record;
return true;
}
private static bool Refuse(SetReleaseGroupResult result, string reason)
{
result.Outcome = SetReleaseGroupOutcome.Refused;
result.Error = reason;
return false;
}
// Only nests on the server, or whose upload failed, are kept for the caller to show.
private static void DiscardBuilt(IEnumerable results)
{
foreach (var result in results.Where(r => r.Outcome is SetReleaseGroupOutcome.NotStarted or SetReleaseGroupOutcome.Refused))
result.Nest = null;
}
private static string Describe(SetReleaseGroup group) =>
string.IsNullOrWhiteSpace(group.MaterialGrade)
? $"{group.MaterialName} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}"
: $"{group.MaterialName} {group.MaterialGrade} {group.Thickness:0.####} {group.CutType.ToString().ToLowerInvariant()}";
private static string Name(Units units) => units.ToString().ToLowerInvariant();
}