using OpenNest; namespace OpenNest.Data; public enum SetReleaseGroupOutcome { /// The group's nest is on the server; 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 nest: its id and revision. public NestRecord? Record { 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(); } public enum SetReleaseStage { /// Downloading the group's drawings and building its nest; nothing is uploaded yet. Preparing, /// Uploading the group's nest. 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 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). /// /// 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. 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 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. /// public SetReleaseSession(IDrawingSetStore sets, IDrawingLibrary library, INestRepository nests, string serverUrl, Func readPackage, Func writeNest, Func> createNest) { _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 . Call once per session. public async Task RunAsync(SetReleasePlan plan, IReadOnlyCollection groups, string releasedBy, string notes, IProgress? progress = null, CancellationToken cancellationToken = default) { 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 (_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(); // Build every nest first: a refusal here leaves nothing on the server. for (var i = 0; i < results.Count; i++) { var result = results[i]; progress?.Report(new SetReleaseProgress(SetReleaseStage.Preparing, i, results.Count, result.Group)); bool ready; try { ready = await PrepareAsync(plan, result, 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(), SetId = plan.Set.Id, SetRevision = plan.Set.Revision, Multiplier = plan.Multiplier, Notes = notes ?? "", ReleasedBy = releasedBy ?? "", Nests = results.Select(r => new SetReleaseNest { NestId = r.Record!.Id, 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; 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)}."); 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; 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."); 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(); } 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) { 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; } private async Task PostPendingAsync(CancellationToken cancellationToken) { var release = _pendingRelease!; SetReleaseRecord stored; try { stored = await _sets.PostReleaseAsync(release.SetId, 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(); }