diff --git a/OpenNest.Data/MaterialLedger/LedgerHoldSummary.cs b/OpenNest.Data/MaterialLedger/LedgerHoldSummary.cs new file mode 100644 index 00000000..c77ea34b --- /dev/null +++ b/OpenNest.Data/MaterialLedger/LedgerHoldSummary.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenNest.Data.MaterialLedger; + +/// How a nest's open ledger holds compare with the sheets it cuts. +public enum LedgerHoldState +{ + /// Every sheet has an open hold (possibly more holds than sheets). + Held, + + /// Some sheets have an open hold and some do not. + Short, + + /// No open holds, whether or not closed ones exist. + None, + + /// The ledger did not answer, or rejected the API key. + Unavailable, +} + +/// +/// A read-only summary of a server nest's MaterialLedger holds against its physical sheet +/// count, for the desktop's status line and tooltip. Only open statuses +/// () count as held. +/// +public sealed class LedgerHoldSummary +{ + private const int MaxTooltipHolds = 20; + private const double MillimetersPerInch = 25.4; + + private LedgerHoldSummary(LedgerHoldState state, int sheets, IReadOnlyList holds, string? reason) + { + State = state; + Sheets = sheets; + Holds = holds; + Reason = reason; + OpenHolds = holds.Count(hold => LedgerHoldStatuses.IsOpen(hold.Status)); + ClosedHolds = holds.Count - OpenHolds; + } + + public LedgerHoldState State { get; } + + /// The nest's physical sheets () when it was summarized. + public int Sheets { get; } + + /// Every hold the ledger returned, open or not, in its order. + public IReadOnlyList Holds { get; } + + public int OpenHolds { get; } + + /// Holds in any other status: closed, cancelled, exception or unknown. + public int ClosedHolds { get; } + + /// Sheets without an open hold. + public int ShortSheets => System.Math.Max(0, Sheets - OpenHolds); + + /// Why the ledger is unavailable; null otherwise. + public string? Reason { get; } + + /// The status line, e.g. "Ledger: 2 of 4 held, 2 short". + public string Text => State switch + { + LedgerHoldState.Unavailable => "Ledger: unavailable", + LedgerHoldState.None => ClosedHolds == 0 + ? "Ledger: no holds" + : $"Ledger: no open holds ({ClosedHolds} closed)", + LedgerHoldState.Short => $"Ledger: {OpenHolds} of {Sheets} held, {ShortSheets} short", + _ => OpenHolds > Sheets + ? $"Ledger: {Sheets} of {Sheets} sheets held, {OpenHolds - Sheets} extra" + : $"Ledger: {Sheets} of {Sheets} sheets held", + }; + + /// + /// Sheets the nest cuts: the sum of plate quantities, skipping a trailing plate with no + /// material parts and no cut-offs as the server's /sheets does (it is the editor's + /// new-plate slot). This is what MaterialLedger makes one hold per sheet from. + /// + public static int PhysicalSheetCount(Nest nest) + { + ArgumentNullException.ThrowIfNull(nest); + var plates = nest.Plates; + var count = plates.Count; + if (count > 0 && plates[count - 1] is { } last + && last.Parts.All(part => part.BaseDrawing.IsCutOff) && last.CutOffs.Count == 0) + count--; + + var sheets = 0; + for (var i = 0; i < count; i++) + sheets += System.Math.Max(0, plates[i].Quantity); + return sheets; + } + + /// + /// Summarizes against ; null when the + /// ledger is not configured, so callers show nothing. + /// + public static LedgerHoldSummary? From(LedgerHoldsResult result, int sheets) + { + ArgumentNullException.ThrowIfNull(result); + sheets = System.Math.Max(0, sheets); + switch (result) + { + case LedgerHoldsResult.NotConfigured: + return null; + case LedgerHoldsResult.Unauthorized: + return new(LedgerHoldState.Unavailable, sheets, Array.Empty(), + "MaterialLedger rejected the API key. Check it in Options."); + case LedgerHoldsResult.Unreachable unreachable: + return new(LedgerHoldState.Unavailable, sheets, Array.Empty(), unreachable.Reason); + case LedgerHoldsResult.Ok ok: + var open = ok.Holds.Count(hold => LedgerHoldStatuses.IsOpen(hold.Status)); + var state = open == 0 ? LedgerHoldState.None + : open < sheets ? LedgerHoldState.Short + : LedgerHoldState.Held; + return new(state, sheets, ok.Holds, null); + default: + throw new ArgumentOutOfRangeException(nameof(result), result, null); + } + } + + /// The status line, then one line per hold with its size in . + public string ToolTip(Units units) + { + var text = new StringBuilder(Text); + if (Reason != null) + text.AppendLine().Append(Reason); + foreach (var hold in Holds.Take(MaxTooltipHolds)) + text.AppendLine().Append(FormatHold(hold, units)); + if (Holds.Count > MaxTooltipHolds) + text.AppendLine().Append($"+{Holds.Count - MaxTooltipHolds} more"); + return text.ToString(); + } + + /// One hold, e.g. "DROP-1182 30 x 52.5 in B3 Approved". + public static string FormatHold(LedgerHold hold, Units units) + { + ArgumentNullException.ThrowIfNull(hold); + var parts = new List { hold.ItemCode, FormatSize(hold.WidthIn, hold.LengthIn, units) }; + if (!string.IsNullOrWhiteSpace(hold.LocationCode)) + parts.Add(hold.LocationCode.Trim()); + parts.Add(DisplayStatus(hold.Status)); + return string.Join(" ", parts); + } + + /// A ledger size (inches) in nest units, e.g. "30 x 52.5 in" or "762 x 1333.5 mm". + public static string FormatSize(decimal widthIn, decimal lengthIn, Units units) + { + var scale = units == Units.Millimeters ? MillimetersPerInch : 1.0; + var format = units == Units.Millimeters ? "0.#" : "0.###"; + return ((double)widthIn * scale).ToString(format, CultureInfo.CurrentCulture) + " x " + + ((double)lengthIn * scale).ToString(format, CultureInfo.CurrentCulture) + " " + + UnitsHelper.GetShortString(units); + } + + /// An ML status name spaced for display ("AwaitingCloseout" → "Awaiting Closeout"). + public static string DisplayStatus(string? status) => + string.IsNullOrWhiteSpace(status) ? "(no status)" : Regex.Replace(status.Trim(), "(?<=[a-z])(?=[A-Z])", " "); +} diff --git a/OpenNest.Data/MaterialLedger/LedgerHolds.cs b/OpenNest.Data/MaterialLedger/LedgerHolds.cs new file mode 100644 index 00000000..13a1f1ae --- /dev/null +++ b/OpenNest.Data/MaterialLedger/LedgerHolds.cs @@ -0,0 +1,59 @@ +namespace OpenNest.Data.MaterialLedger; + +/// The body of MaterialLedger's GET /api/stock/holds?openNestNestId=. +public sealed record LedgerHoldsResponse(Guid OpenNestNestId, IReadOnlyList? Holds); + +/// +/// One ML MaterialReservation for a sheet of the nest. Sizes are inches with +/// the short side. stays a string so a new ML +/// ReservationStatus value still deserializes. +/// +public sealed record LedgerHold( + long MaterialReservationId, + string ItemCode, + decimal WidthIn, + decimal LengthIn, + string Status, + string? LocationCode, + DateTime CreatedAtUtc); + +/// ML ReservationStatus names. +public static class LedgerHoldStatuses +{ + public const string Proposed = "Proposed"; + public const string Approved = "Approved"; + public const string Released = "Released"; + public const string AwaitingCloseout = "AwaitingCloseout"; + public const string ClosedConsumed = "ClosedConsumed"; + public const string ClosedReturned = "ClosedReturned"; + public const string Cancelled = "Cancelled"; + public const string Exception = "Exception"; + + /// + /// True for holds that still keep a sheet for the nest and need closing out. Closed, + /// cancelled, exception and unknown statuses are not open. + /// + public static bool IsOpen(string? status) => + string.Equals(status, Proposed, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, Approved, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, Released, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, AwaitingCloseout, StringComparison.OrdinalIgnoreCase); +} + +/// What a ledger hold request came to. Expected failures are results, never exceptions. +public abstract record LedgerHoldsResult +{ + private LedgerHoldsResult() { } + + /// No ledger URL is set on this PC. + public sealed record NotConfigured : LedgerHoldsResult; + + /// The ledger did not answer usefully: network error, timeout, bad status or body. + public sealed record Unreachable(string Reason) : LedgerHoldsResult; + + /// The ledger rejected the API key (401/403). + public sealed record Unauthorized : LedgerHoldsResult; + + /// The nest's holds in every status; empty when ML has none for it. + public sealed record Ok(IReadOnlyList Holds) : LedgerHoldsResult; +} diff --git a/OpenNest.Data/MaterialLedger/MaterialLedgerClient.cs b/OpenNest.Data/MaterialLedger/MaterialLedgerClient.cs index 641a9fbc..a01a6187 100644 --- a/OpenNest.Data/MaterialLedger/MaterialLedgerClient.cs +++ b/OpenNest.Data/MaterialLedger/MaterialLedgerClient.cs @@ -6,9 +6,10 @@ using System.Text.Json; namespace OpenNest.Data.MaterialLedger; /// -/// Read-only client for MaterialLedger's stock API. It never writes to the ledger, and what it -/// reads is a snapshot, not a reservation. Expected failures come back as -/// cases; only the caller's own cancellation throws. +/// Read-only client for MaterialLedger's API. It never writes to the ledger, and what it +/// reads is a snapshot, not a reservation. Expected failures come back as result cases +/// (, ); only the caller's own +/// cancellation throws. /// public sealed class MaterialLedgerClient { @@ -40,56 +41,99 @@ public sealed class MaterialLedgerClient { if (!settings.IsConfigured) return new LedgerStockResult.NotConfigured(); - if (!Uri.TryCreate(settings.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var baseUri) - || baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps) - return new LedgerStockResult.Unreachable($"'{settings.BaseUrl}' is not an http or https address."); var query = "api/stock/opennest" + "?material=" + Uri.EscapeDataString(material ?? "") + "&grade=" + Uri.EscapeDataString(grade ?? "") + "&thicknessIn=" + System.Math.Round(thicknessIn, 4).ToString("0.####", CultureInfo.InvariantCulture); + var reply = await GetJsonAsync(query, "stock", cancellationToken); + if (reply.Unauthorized) + return new LedgerStockResult.Unauthorized(); + if (reply.Body is not { } stock) + return new LedgerStockResult.Unreachable(reply.Failure!); + if (!stock.Mapped) + return new LedgerStockResult.Unmapped(); + return new LedgerStockResult.Ok(stock.Specs ?? Array.Empty(), + stock.Items ?? Array.Empty()); + } + + /// + /// Every hold ML has for the sheets of server nest , in + /// whatever status; the caller decides which are open. + /// + public async Task GetHoldsAsync(Guid openNestNestId, CancellationToken cancellationToken) + { + if (!settings.IsConfigured) + return new LedgerHoldsResult.NotConfigured(); + + var query = "api/stock/holds?openNestNestId=" + openNestNestId.ToString("D"); + var reply = await GetJsonAsync(query, "holds", cancellationToken); + if (reply.Unauthorized) + return new LedgerHoldsResult.Unauthorized(); + if (reply.Body is not { } holds) + return new LedgerHoldsResult.Unreachable(reply.Failure!); + return new LedgerHoldsResult.Ok((holds.Holds ?? Array.Empty()) + .Where(hold => hold != null) + .Select(hold => hold with { ItemCode = hold.ItemCode ?? "", Status = hold.Status ?? "" }) + .ToList()); + } + + /// + /// GETs below the ledger's base URL with the API key and the + /// per-request timeout. names the route in the 404 message. + /// + private async Task> GetJsonAsync(string pathAndQuery, string route, + CancellationToken cancellationToken) where T : class + { + if (!Uri.TryCreate(settings.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute, out var baseUri) + || baseUri.Scheme != Uri.UriSchemeHttp && baseUri.Scheme != Uri.UriSchemeHttps) + return Reply.Failed($"'{settings.BaseUrl}' is not an http or https address."); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(timeout); try { - using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, query)); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(baseUri, pathAndQuery)); if (!string.IsNullOrEmpty(settings.ApiKey)) request.Headers.TryAddWithoutValidation(ApiKeyHeader, settings.ApiKey); using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutSource.Token); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) - return new LedgerStockResult.Unauthorized(); + return new Reply(null, true, null); if (response.StatusCode == HttpStatusCode.NotFound) - return new LedgerStockResult.Unreachable("This MaterialLedger has no OpenNest stock route; update MaterialLedger."); + return Reply.Failed($"This MaterialLedger has no OpenNest {route} route; update MaterialLedger."); if (!response.IsSuccessStatusCode) - return new LedgerStockResult.Unreachable($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd()); + return Reply.Failed($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd()); await using var body = await response.Content.ReadAsStreamAsync(timeoutSource.Token); - var stock = await JsonSerializer.DeserializeAsync(body, JsonOptions, timeoutSource.Token); - if (stock == null) - return new LedgerStockResult.Unreachable("The ledger returned an empty response."); - if (!stock.Mapped) - return new LedgerStockResult.Unmapped(); - return new LedgerStockResult.Ok(stock.Specs ?? Array.Empty(), - stock.Items ?? Array.Empty()); + var value = await JsonSerializer.DeserializeAsync(body, JsonOptions, timeoutSource.Token); + return value == null + ? Reply.Failed("The ledger returned an empty response.") + : new Reply(value, false, null); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - return new LedgerStockResult.Unreachable( + return Reply.Failed( $"No answer within {timeout.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} s."); } catch (HttpRequestException ex) { - return new LedgerStockResult.Unreachable(ex.Message); + return Reply.Failed(ex.Message); } catch (JsonException) { - return new LedgerStockResult.Unreachable("The ledger's response could not be read."); + return Reply.Failed("The ledger's response could not be read."); } catch (IOException ex) { - return new LedgerStockResult.Unreachable(ex.Message); + return Reply.Failed(ex.Message); } } + + /// A parsed body, a rejected key, or why the ledger was unreachable. + private readonly record struct Reply(T? Body, bool Unauthorized, string? Failure) where T : class + { + public static Reply Failed(string reason) => new(null, false, reason); + } } diff --git a/OpenNest.Tests/Data/LedgerHoldSummaryTests.cs b/OpenNest.Tests/Data/LedgerHoldSummaryTests.cs new file mode 100644 index 00000000..b1e75d14 --- /dev/null +++ b/OpenNest.Tests/Data/LedgerHoldSummaryTests.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using OpenNest.CNC; +using OpenNest.Data.MaterialLedger; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Data; + +public class LedgerHoldSummaryTests +{ + [Fact] + public void SheetCountSumsPlateQuantitiesAndSkipsTheTrailingEmptyPlate() + { + var nest = new Nest("N"); + nest.Plates.Add(WithPart(quantity: 3)); + nest.Plates.Add(new Plate { Quantity = 2 }); // Interior: /sheets still lists it. + nest.Plates.Add(WithPart(quantity: 1)); + nest.Plates.Add(new Plate { Quantity = 5 }); // The editor's new-plate slot. + + Assert.Equal(6, LedgerHoldSummary.PhysicalSheetCount(nest)); + Assert.Equal(0, LedgerHoldSummary.PhysicalSheetCount(new Nest("Empty"))); + } + + [Fact] + public void AllSheetsHeld() + { + var summary = Summarize(4, Hold("Approved"), Hold("Released"), Hold("Proposed"), Hold("AwaitingCloseout")); + Assert.Equal(LedgerHoldState.Held, summary.State); + Assert.Equal("Ledger: 4 of 4 sheets held", summary.Text); + Assert.Equal(0, summary.ShortSheets); + } + + [Fact] + public void ClosedAndUnknownHoldsDoNotCountAsHeld() + { + var summary = Summarize(4, Hold("Approved"), Hold("Released"), Hold("ClosedConsumed"), + Hold("Cancelled"), Hold("Exception"), Hold("OnTruck")); + Assert.Equal(LedgerHoldState.Short, summary.State); + Assert.Equal((2, 4, 2), (summary.OpenHolds, summary.ClosedHolds, summary.ShortSheets)); + Assert.Equal("Ledger: 2 of 4 held, 2 short", summary.Text); + } + + [Fact] + public void NoHoldsAndNoOpenHolds() + { + var none = Summarize(4); + Assert.Equal(LedgerHoldState.None, none.State); + Assert.Equal("Ledger: no holds", none.Text); + + var closed = Summarize(2, Hold("ClosedConsumed"), Hold("ClosedReturned")); + Assert.Equal(LedgerHoldState.None, closed.State); + Assert.Equal("Ledger: no open holds (2 closed)", closed.Text); + } + + [Fact] + public void MoreHoldsThanSheetsIsHeldWithExtras() + { + var summary = Summarize(1, Hold("Approved"), Hold("Approved")); + Assert.Equal(LedgerHoldState.Held, summary.State); + Assert.Equal("Ledger: 1 of 1 sheets held, 1 extra", summary.Text); + } + + [Fact] + public void FailuresAreUnavailableAndNotConfiguredShowsNothing() + { + Assert.Null(LedgerHoldSummary.From(new LedgerHoldsResult.NotConfigured(), 4)); + + var down = LedgerHoldSummary.From(new LedgerHoldsResult.Unreachable("timed out"), 4)!; + Assert.Equal((LedgerHoldState.Unavailable, "Ledger: unavailable", "timed out"), (down.State, down.Text, down.Reason)); + + var rejected = LedgerHoldSummary.From(new LedgerHoldsResult.Unauthorized(), 4)!; + Assert.Equal(LedgerHoldState.Unavailable, rejected.State); + Assert.Contains("API key", rejected.Reason); + } + + [Fact] + public void ToolTipListsHoldsInNestUnits() + { + var previous = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + try + { + var summary = Summarize(2, new LedgerHold(91, "DROP-1182", 30, 52.5m, "AwaitingCloseout", "B3", default), + new LedgerHold(92, "FS-1", 48, 96, "Approved", null, default)); + + Assert.Equal("Ledger: 2 of 2 sheets held" + Environment.NewLine + + "DROP-1182 30 x 52.5 in B3 Awaiting Closeout" + Environment.NewLine + + "FS-1 48 x 96 in Approved", summary.ToolTip(Units.Inches)); + Assert.Equal("DROP-1182 762 x 1333.5 mm B3 Awaiting Closeout", + LedgerHoldSummary.FormatHold(summary.Holds[0], Units.Millimeters)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + private static LedgerHoldSummary Summarize(int sheets, params LedgerHold[] holds) => + LedgerHoldSummary.From(new LedgerHoldsResult.Ok(holds), sheets)!; + + private static LedgerHold Hold(string status) => new(1, "X", 48, 96, status, "A1", default); + + private static Plate WithPart(int quantity) + { + var program = new Program(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(10, 0))); + program.Codes.Add(new LinearMove(new Vector(10, 10))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + var plate = new Plate { Quantity = quantity }; + plate.Parts.Add(new Part(new Drawing("test", program))); + return plate; + } +} diff --git a/OpenNest.Tests/Data/MaterialLedgerClientTests.cs b/OpenNest.Tests/Data/MaterialLedgerClientTests.cs index 3427db21..6d93731d 100644 --- a/OpenNest.Tests/Data/MaterialLedgerClientTests.cs +++ b/OpenNest.Tests/Data/MaterialLedgerClientTests.cs @@ -139,6 +139,92 @@ public class MaterialLedgerClientTests Assert.Equal("Circular", item.Shape); } + private static readonly Guid NestId = Guid.Parse("4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b"); + + [Fact] + public async Task HoldsAreListedWithTheKeyAndNestId() + { + HttpRequestMessage? seen = null; + var client = Client(request => + { + seen = request; + return Json(""" + { + "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", + "holds": [ + { "materialReservationId": 91, "itemCode": "DROP-1182", "widthIn": 30, "lengthIn": 52.5, + "status": "Approved", "locationCode": "B3", "createdAtUtc": "2026-10-20T14:00:00Z" }, + { "materialReservationId": 92, "itemCode": "FS-4896", "widthIn": 48, "lengthIn": 96, + "status": "ClosedConsumed", "locationCode": null, "createdAtUtc": "2026-10-20T14:00:00Z" } + ] + } + """); + }); + + var ok = Assert.IsType(await client.GetHoldsAsync(NestId, default)); + + Assert.Equal("/api/stock/holds?openNestNestId=4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", seen!.RequestUri!.PathAndQuery); + Assert.Equal("secret-key", Assert.Single(seen.Headers.GetValues(MaterialLedgerClient.ApiKeyHeader))); + Assert.Equal(2, ok.Holds.Count); + var first = ok.Holds[0]; + Assert.Equal((91L, "DROP-1182", 30m, 52.5m, LedgerHoldStatuses.Approved, "B3"), + (first.MaterialReservationId, first.ItemCode, first.WidthIn, first.LengthIn, first.Status, first.LocationCode)); + Assert.Null(ok.Holds[1].LocationCode); + } + + [Fact] + public async Task NoHoldsIsAnEmptyList() + { + var client = Client(_ => Json("""{ "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", "holds": [] }""")); + Assert.Empty(Assert.IsType(await client.GetHoldsAsync(NestId, default)).Holds); + + var missing = Client(_ => Json("""{ "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b" }""")); + Assert.Empty(Assert.IsType(await missing.GetHoldsAsync(NestId, default)).Holds); + } + + [Fact] + public async Task UnknownHoldStatusIsKept() + { + var client = Client(_ => Json(""" + { + "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b", + "holds": [{ "materialReservationId": 5, "itemCode": "D-1", "widthIn": 10, "lengthIn": 20, + "status": "OnTruck", "locationCode": "Y", "createdAtUtc": "2026-10-20T14:00:00Z", "someNewField": 1 }] + } + """)); + var hold = Assert.Single(Assert.IsType(await client.GetHoldsAsync(NestId, default)).Holds); + Assert.Equal("OnTruck", hold.Status); + Assert.False(LedgerHoldStatuses.IsOpen(hold.Status)); + } + + [Fact] + public async Task HoldFailuresAreResults() + { + var sent = 0; + var notConfigured = Client(_ => { sent++; return Json("{}"); }, new MaterialLedgerSettings()); + Assert.IsType(await notConfigured.GetHoldsAsync(NestId, default)); + Assert.Equal(0, sent); + + var down = Client(_ => throw new HttpRequestException("No such host is known.")); + Assert.Contains("No such host", Assert.IsType( + await down.GetHoldsAsync(NestId, default)).Reason); + + var oldLedger = Client(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + Assert.Contains("no OpenNest holds route", Assert.IsType( + await oldLedger.GetHoldsAsync(NestId, default)).Reason); + + var rejected = Client(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized)); + Assert.IsType(await rejected.GetHoldsAsync(NestId, default)); + + var garbage = Client(_ => Json("login")); + Assert.IsType(await garbage.GetHoldsAsync(NestId, default)); + + using var http = new HttpClient(new HangingHandler()); + var slow = new MaterialLedgerClient(http, Configured, TimeSpan.FromMilliseconds(50)); + Assert.Contains("No answer", Assert.IsType( + await slow.GetHoldsAsync(NestId, default)).Reason); + } + private static MaterialLedgerClient Client(Func reply, MaterialLedgerSettings? settings = null) => new(new HttpClient(new Handler(reply)), settings ?? Configured); diff --git a/OpenNest.WinForms.Tests/Forms/NestHoldStatusTests.cs b/OpenNest.WinForms.Tests/Forms/NestHoldStatusTests.cs new file mode 100644 index 00000000..c3513473 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/NestHoldStatusTests.cs @@ -0,0 +1,176 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Windows.Forms; +using OpenNest.Data.MaterialLedger; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +public class NestHoldStatusTests +{ + private const string ServerUrl = "https://nest-storage.example.test"; + private static readonly MaterialLedgerSettings Ledger = new() { BaseUrl = "http://ledger:5000", ApiKey = "k" }; + + [Fact] + public async Task FileModeNestsShowNoHoldsAndNeverAskTheLedger() + { + var requests = 0; + var status = Status(Bound(), databaseMode: false, _ => { requests++; return Holds("Approved"); }); + + await status.RefreshAfterSaveAsync(); + await status.NoteCutAsync(); + + Assert.False(status.IsApplicable); + Assert.False(status.IsVisible); + Assert.Null(status.Summary); + Assert.Equal(0, requests); + } + + [Fact] + public async Task UnsavedNestsAndUnconfiguredLedgersShowNothing() + { + var requests = 0; + var unsaved = Status(new Document { Nest = Nest(1) }, databaseMode: true, _ => { requests++; return Holds(); }); + await unsaved.RefreshAfterSaveAsync(); + Assert.False(unsaved.IsVisible); + + var noLedger = Status(Bound(), databaseMode: true, _ => { requests++; return Holds(); }, new MaterialLedgerSettings()); + await noLedger.RefreshAfterSaveAsync(); + Assert.False(noLedger.IsVisible); + Assert.Equal(0, requests); + } + + [Fact] + public async Task ServerNestsSummarizeHoldsAgainstSavedSheets() + { + var document = Bound(sheets: 4); + var changes = 0; + HttpRequestMessage seen = null; + var status = Status(document, databaseMode: true, request => { seen = request; return Holds("Approved", "Released"); }); + status.Changed += (_, _) => changes++; + + await status.RefreshAfterSaveAsync(); + + Assert.True(status.IsVisible); + Assert.False(status.IsRefreshing); + Assert.Equal("Ledger: 2 of 4 held, 2 short", status.Summary.Text); + Assert.Contains(document.RemoteId.ToString("D"), seen.RequestUri!.Query); + Assert.Equal($"http://ledger:5000/Nests/{document.RemoteId:D}", status.LedgerNestUrl); + Assert.Equal(2, changes); // Refreshing, then the answer. + + // An on-demand refresh counts the sheets of the last save, not unsaved edits. + document.Nest.Plates[0].Quantity = 10; + await status.RefreshAsync(); + Assert.Equal(4, status.Summary.Sheets); + } + + [Fact] + public async Task UnreachableLedgerIsShownAsUnavailable() + { + var status = Status(Bound(), databaseMode: true, _ => throw new HttpRequestException("down")); + await status.RefreshAfterSaveAsync(); + Assert.Equal("Ledger: unavailable", status.Summary.Text); + } + + [Fact] + public async Task CutNoteAppearsOnlyWithOpenHolds() + { + var open = Status(Bound(), databaseMode: true, _ => Holds("Approved", "AwaitingCloseout", "ClosedConsumed")); + var noted = new List(); + open.CloseoutNoteRequested += (_, count) => noted.Add(count); + await open.NoteCutAsync(); + Assert.Equal(new[] { 2 }, noted); + + foreach (var reply in new Func[] + { + _ => Holds(), + _ => Holds("ClosedConsumed", "Cancelled"), + _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + }) + { + var closed = Status(Bound(), databaseMode: true, reply); + closed.CloseoutNoteRequested += (_, count) => noted.Add(count); + await closed.NoteCutAsync(); + } + Assert.Equal(new[] { 2 }, noted); + } + + [Fact] + public void CloseoutNoteLinksToTheLedgerNest() => RunSta(() => + { + using var note = new LedgerCloseoutNote("N-1", 4, "http://ledger:5000/Nests/abc"); + Assert.Contains("Close out 4 holds in MaterialLedger", note.Message.Text); + Assert.Equal("http://ledger:5000/Nests/abc", note.Link.Tag); + Assert.Contains("Close out 1 hold in", LedgerCloseoutNote.MessageText("N-1", 1)); + }); + + [Fact] + public void StatusBarHidesTheLedgerLabelForFileModeNests() => RunSta(() => + { + using var form = new EditNestForm(Nest(2)); + var ledger = MainStatusBar.CreateLedgerLabel(); + ledger.Visible = true; + ledger.Text = "stale"; + var bar = new MainStatusBar(new ToolStripStatusLabel(), new ToolStripStatusLabel(), new ToolStripStatusLabel(), + new ToolStripStatusLabel(), new ToolStripStatusLabel(), new ToolStripStatusLabel(), ledger); + + bar.Bind(form); + + Assert.False(form.HoldStatus.IsVisible); // Never saved to the server. + Assert.False(ledger.Visible); + Assert.Equal("", ledger.Text); + bar.Unbind(); + }); + + private static NestHoldStatus Status(Document document, bool databaseMode, + Func reply, MaterialLedgerSettings settings = null) => + new(document, () => databaseMode, () => ServerUrl, () => settings ?? Ledger, + s => new MaterialLedgerClient(new HttpClient(new Handler(reply)), s)); + + private static Document Bound(int sheets = 1) + { + var document = new Document { Nest = Nest(sheets) }; + document.BindRemote(Guid.NewGuid(), ServerUrl, 3); + return document; + } + + private static Nest Nest(int sheets) + { + var nest = new Nest("N-1"); + var plate = new Plate(48, 96) { Quantity = sheets }; + plate.Parts.Add(new Part(Square())); + nest.Plates.Add(plate); + return nest; + } + + private static Drawing Square() + { + var program = new CNC.Program(); + program.Codes.Add(new CNC.RapidMove(new Geometry.Vector(0, 0))); + program.Codes.Add(new CNC.LinearMove(new Geometry.Vector(4, 0))); + program.Codes.Add(new CNC.LinearMove(new Geometry.Vector(4, 4))); + program.Codes.Add(new CNC.LinearMove(new Geometry.Vector(0, 0))); + return new Drawing("square", program); + } + + private static HttpResponseMessage Holds(params string[] statuses) + { + var holds = string.Join(",", statuses.Select((status, i) => + $$"""{ "materialReservationId": {{i + 1}}, "itemCode": "S-{{i}}", "widthIn": 48, "lengthIn": 96, "status": "{{status}}", "locationCode": "A1", "createdAtUtc": "2026-10-20T14:00:00Z" }""")); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($$"""{ "openNestNestId": "00000000-0000-0000-0000-000000000000", "holds": [{{holds}}] }""", + Encoding.UTF8, "application/json"), + }; + } + + private sealed class Handler(Func reply) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(reply(request)); + } + + private static void RunSta(Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete."); +} diff --git a/OpenNest/Forms/Commands/NestStorageController.cs b/OpenNest/Forms/Commands/NestStorageController.cs index 46bceb03..e99e7d2d 100644 --- a/OpenNest/Forms/Commands/NestStorageController.cs +++ b/OpenNest/Forms/Commands/NestStorageController.cs @@ -130,7 +130,10 @@ namespace OpenNest.Forms.Commands var saved = await form.Document.SaveToDatabaseAsync( repository, serverUrl, saveCopy); if (!form.IsDisposed) + { form.Text = form.Document.Name; + _ = form.HoldStatus.RefreshAfterSaveAsync(); + } if (!owner.IsDisposed) showStatus($"Saved {saved.Name} to nest database"); } diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index cd41a162..d4b25fe2 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -45,6 +45,9 @@ namespace OpenNest.Forms public Nest Nest => Document.Nest; + /// The nest's MaterialLedger holds; shown only for saved server nests. + public readonly NestHoldStatus HoldStatus; + private readonly Timer updateDrawingListTimer; private bool updatingPlateList; private bool addingToSet; @@ -434,6 +437,9 @@ namespace OpenNest.Forms updateDrawingListTimer.Elapsed += drawingListUpdateTimer_Elapsed; Document = new Document { Nest = nest }; + HoldStatus = new NestHoldStatus(Document); + HoldStatus.CloseoutNoteRequested += HoldStatus_CloseoutNoteRequested; + FormClosed += (_, _) => HoldStatus.Dispose(); // Units are read when each check starts, matching the manual command. PlateView.SetOverlapAutoCheck(() => Nest.Units); CutOffHeadLockDefaults.Load(PlateView.CutOffSettings, nest.Units); @@ -771,6 +777,7 @@ namespace OpenNest.Forms form.SetMaterialCatalog(MachineSettings.LoadMaterials()); form.LoadNestInfo(Nest); _ = ShowReleaseOriginAsync(form); + var statusBefore = Nest.Status; while (form.ShowDialog() == System.Windows.Forms.DialogResult.OK) { @@ -784,10 +791,21 @@ namespace OpenNest.Forms Text = Nest.Name; drawingListBox1.Units = Nest.Units; drawingListBox1.Invalidate(); + if (statusBefore != NestStatus.HasBeenCut && Nest.Status == NestStatus.HasBeenCut) + _ = HoldStatus.NoteCutAsync(); return; } } + private void HoldStatus_CloseoutNoteRequested(object sender, int openHolds) + { + if (IsDisposed) + return; + var note = new LedgerCloseoutNote(Nest.Name, openHolds, HoldStatus.LedgerNestUrl); + note.FormClosed += (_, _) => note.Dispose(); + note.ShowBeside(MdiParent ?? this); + } + /// /// Shows which drawing set releases made or merged into this nest, when it is a server /// nest that came from one. Best effort and in the background: Nest Info never waits for the server. diff --git a/OpenNest/Forms/LedgerCloseoutNote.cs b/OpenNest/Forms/LedgerCloseoutNote.cs new file mode 100644 index 00000000..8336cc99 --- /dev/null +++ b/OpenNest/Forms/LedgerCloseoutNote.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.Windows.Forms; + +namespace OpenNest.Forms +{ + /// + /// A non-modal reminder, shown when a nest is marked Has Been Cut while MaterialLedger still + /// holds sheets for it, that links to the nest's ledger page. It never blocks or gates anything; + /// holds are closed out only in MaterialLedger. + /// + internal sealed class LedgerCloseoutNote : Form + { + public LedgerCloseoutNote(string nestName, int openHolds, string ledgerNestUrl) + { + Text = "MaterialLedger"; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Padding = new Padding(12); + + Message = new Label + { + AutoSize = true, + MaximumSize = new Size(360, 0), + Text = MessageText(nestName, openHolds), + }; + Link = new LinkLabel { AutoSize = true, Text = "Open the nest in MaterialLedger", Tag = ledgerNestUrl }; + Link.LinkClicked += (_, _) => OpenLink(ledgerNestUrl); + var close = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.Cancel }; + close.Click += (_, _) => Close(); + CancelButton = close; + + var layout = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + Dock = DockStyle.Fill, + }; + Message.Margin = new Padding(0, 0, 0, 8); + Link.Margin = new Padding(0, 0, 0, 12); + layout.Controls.AddRange(new Control[] { Message, Link, close }); + Controls.Add(layout); + } + + internal Label Message { get; } + + internal LinkLabel Link { get; } + + internal static string MessageText(string nestName, int openHolds) + { + var holds = openHolds == 1 ? "1 hold" : $"{openHolds} holds"; + var nest = string.IsNullOrWhiteSpace(nestName) ? "This nest" : $"\"{nestName}\""; + return $"{nest} is marked Has Been Cut. Close out {holds} in MaterialLedger."; + } + + /// Shows the note beside 's lower right corner without taking over input. + public void ShowBeside(Form owner) + { + var area = owner != null ? owner.RectangleToScreen(owner.ClientRectangle) : Screen.PrimaryScreen.WorkingArea; + var size = GetPreferredSize(Size.Empty); + Location = new Point(System.Math.Max(area.Left, area.Right - size.Width - 24), + System.Math.Max(area.Top, area.Bottom - size.Height - 48)); + Show(owner); + } + + private static void OpenLink(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) + || parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) + return; + try + { + Process.Start(new ProcessStartInfo(parsed.AbsoluteUri) { UseShellExecute = true }); + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + // No browser registered; the note's text still says what to do. + } + } + } +} diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs index 942c6076..62a4803d 100644 --- a/OpenNest/Forms/MainForm.File.cs +++ b/OpenNest/Forms/MainForm.File.cs @@ -35,6 +35,15 @@ namespace OpenNest.Forms editForm.WindowState = windowState; editForm.Show(); editForm.PlateView.ZoomToFit(); + if (remoteId != Guid.Empty) + _ = editForm.HoldStatus.RefreshAfterSaveAsync(); + } + + /// Rereads every open nest's holds, e.g. after the storage mode or ledger changed. + internal void RefreshLedgerHolds() + { + foreach (var form in MdiChildren.OfType()) + _ = form.HoldStatus.RefreshAfterSaveAsync(); } /// @@ -50,6 +59,7 @@ namespace OpenNest.Forms { shown.Document.BindRemote(record.Id, NestStorage.Settings.ServerUrl, record.Revision); releaseTabs.Remove(shown); + _ = shown.HoldStatus.RefreshAfterSaveAsync(); return; } var windowState = ActiveMdiChild?.WindowState ?? FormWindowState.Maximized; @@ -258,6 +268,7 @@ namespace OpenNest.Forms statusLabel1.Text = $"Nest storage: {dlg.SelectedMode}"; foreach (var form in MdiChildren.OfType()) form.UpdateDrawingLibraryCommand(); + RefreshLedgerHolds(); } catch (Exception ex) { diff --git a/OpenNest/Forms/MainForm.Status.cs b/OpenNest/Forms/MainForm.Status.cs index 3f1faef2..e6bae0b5 100644 --- a/OpenNest/Forms/MainForm.Status.cs +++ b/OpenNest/Forms/MainForm.Status.cs @@ -69,6 +69,13 @@ namespace OpenNest.Forms private void MaterialStatusLabel_Click(object sender, EventArgs e) => EditNest_Click(sender, e); + // Holds are read only on open, save and here; the status bar never polls. + private void LedgerStatusLabel_Click(object sender, EventArgs e) + { + if (activeForm != null) + _ = activeForm.HoldStatus.RefreshAsync(); + } + // Either button lists the machines above the label, as Nest > Machine does. private void MachineStatusLabel_MouseUp(object sender, MouseEventArgs e) { diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs index 76ea4b54..090e5bb8 100644 --- a/OpenNest/Forms/MainForm.Tools.cs +++ b/OpenNest/Forms/MainForm.Tools.cs @@ -167,6 +167,7 @@ namespace OpenNest.Forms { using var form = new OptionsForm(); form.ShowDialog(this); + RefreshLedgerHolds(); // The ledger address or key may have changed. } private void NestDefaults_Click(object sender, EventArgs e) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index a9afe7e6..89131834 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -19,6 +19,7 @@ namespace OpenNest.Forms private EditNestForm activeForm; private readonly MainStatusBar statusBar; + private readonly ToolStripStatusLabel ledgerStatusLabel = MainStatusBar.CreateLedgerLabel(); private bool nestingInProgress; private CancellationTokenSource nestingCts; private readonly NestStorageController storage; @@ -106,8 +107,11 @@ namespace OpenNest.Forms storage = new NestStorageController(this, text => statusLabel1.Text = text); storage.StateChanged += Storage_StateChanged; InitializeComponent(); + statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(materialStatusLabel) + 1, ledgerStatusLabel); + ledgerStatusLabel.Click += LedgerStatusLabel_Click; + statusStrip1.ShowItemToolTips = true; // The hold list lives in the ledger label's tooltip. statusBar = new MainStatusBar(statusLabel1, locationStatusLabel, selectionStatusLabel, - machineStatusLabel, materialStatusLabel, plateUtilStatusLabel); + machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel); toolStrip1.Items.Add(CreateActionButton("btnCutPlanner", "Plan Cutting", Resources.cut_planner, PlatePlanCutting_Click)); toolStrip1.Items.Add(CreateActionButton("btnSimulateCutting", "Simulate Cutting", diff --git a/OpenNest/Forms/MainStatusBar.cs b/OpenNest/Forms/MainStatusBar.cs index 444bb143..6b9cd271 100644 --- a/OpenNest/Forms/MainStatusBar.cs +++ b/OpenNest/Forms/MainStatusBar.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using System.Windows.Forms; using OpenNest.Collections; +using OpenNest.Data.MaterialLedger; using OpenNest.Geometry; using OpenNest.Properties; @@ -12,7 +13,8 @@ namespace OpenNest.Forms { /// /// The main window's status labels for the active nest document: status text, cursor - /// location, selection bounds, nest machine and material, and plate utilization. Plate number, size and + /// location, selection bounds, nest machine and material, MaterialLedger holds of a server nest, + /// and plate utilization. Plate number, size and /// quantity live in the plate view header instead. The location /// follows the mouse (the default, throttled), or only left clicks while click mode is on; /// the mode persists as a user setting. @@ -28,6 +30,7 @@ namespace OpenNest.Forms private readonly ToolStripStatusLabel machine; private readonly ToolStripStatusLabel material; private readonly ToolStripStatusLabel plateUtil; + private readonly ToolStripStatusLabel ledger; private readonly Timer locationTimer; private EditNestForm form; @@ -36,8 +39,9 @@ namespace OpenNest.Forms public MainStatusBar(ToolStripStatusLabel status, ToolStripStatusLabel location, ToolStripStatusLabel selection, ToolStripStatusLabel machine, ToolStripStatusLabel material, - ToolStripStatusLabel plateUtil) + ToolStripStatusLabel plateUtil, ToolStripStatusLabel ledger = null) { + this.ledger = ledger; this.status = status; this.location = location; this.selection = selection; @@ -59,6 +63,7 @@ namespace OpenNest.Forms form = editForm; UpdatePlate(); UpdateLocation(); + UpdateLedger(); if (form == null) { @@ -74,6 +79,7 @@ namespace OpenNest.Forms view.SelectionChanged += PlateView_SelectionChanged; view.PartAdded += PlateView_PartAdded; view.PartRemoved += PlateView_PartRemoved; + form.HoldStatus.Changed += HoldStatus_Changed; status.Text = view.Status; } @@ -90,7 +96,9 @@ namespace OpenNest.Forms view.SelectionChanged -= PlateView_SelectionChanged; view.PartAdded -= PlateView_PartAdded; view.PartRemoved -= PlateView_PartRemoved; + form.HoldStatus.Changed -= HoldStatus_Changed; form = null; + UpdateLedger(); } public void ToggleLocationMode() @@ -106,6 +114,42 @@ namespace OpenNest.Forms UpdateLocation(); UpdatePlate(); UpdateSelection(); + UpdateLedger(); + } + + /// The hidden-by-default hold label MainForm inserts after the material label. + internal static ToolStripStatusLabel CreateLedgerLabel() => new() + { + Name = "ledgerStatusLabel", + BorderSides = ToolStripStatusLabelBorderSides.Left, + Padding = new Padding(5, 0, 5, 0), + Visible = false, + }; + + /// + /// Shows the active nest's hold summary only while it applies (a saved server nest with + /// a ledger configured); the tooltip lists the holds. + /// + public void UpdateLedger() + { + if (ledger == null) + return; + var holds = form?.HoldStatus; + ledger.Visible = holds?.IsVisible == true; + if (!ledger.Visible) + { + ledger.Text = string.Empty; + ledger.ToolTipText = string.Empty; + return; + } + + var summary = holds.Summary; + ledger.Text = summary?.Text ?? "Ledger: checking…"; + ledger.ForeColor = summary?.State is LedgerHoldState.Short or LedgerHoldState.Unavailable + ? Color.DarkRed + : SystemColors.ControlText; + var tip = summary?.ToolTip(form.Nest.Units) ?? "Reading holds from MaterialLedger…"; + ledger.ToolTipText = tip + Environment.NewLine + (holds.IsRefreshing ? "Refreshing…" : "Click to refresh."); } public void UpdateLocation() @@ -267,5 +311,7 @@ namespace OpenNest.Forms private void PlateView_StatusChanged(object sender, EventArgs e) => status.Text = form.PlateView.Status; private void PlateView_SelectionChanged(object sender, EventArgs e) => UpdateSelection(); + + private void HoldStatus_Changed(object sender, EventArgs e) => UpdateLedger(); } } diff --git a/OpenNest/Forms/NestHoldStatus.cs b/OpenNest/Forms/NestHoldStatus.cs new file mode 100644 index 00000000..4e772a63 --- /dev/null +++ b/OpenNest/Forms/NestHoldStatus.cs @@ -0,0 +1,139 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenNest.Data.MaterialLedger; + +namespace OpenNest.Forms +{ + /// + /// A nest document's MaterialLedger holds, read on demand and never polled. Applies only to + /// nests saved to the nest server in Database mode while a ledger is configured; otherwise + /// stays null and nothing is shown. Refreshes run in the background + /// and a newer one supersedes an older one, so neither the UI nor a save waits on the ledger. + /// + public sealed class NestHoldStatus : IDisposable + { + private readonly Document document; + private readonly Func isDatabaseMode; + private readonly Func serverUrl; + private readonly Func ledgerSettings; + private readonly Func createClient; + private CancellationTokenSource current; + private int savedSheets; + + public NestHoldStatus(Document document) + : this(document, () => NestStorage.Settings.IsDatabaseMode, () => NestStorage.Settings.ServerUrl, + () => MaterialLedgerStore.Settings, settings => MaterialLedgerStore.CreateClient(settings)) + { + } + + internal NestHoldStatus(Document document, Func isDatabaseMode, Func serverUrl, + Func ledgerSettings, Func createClient) + { + this.document = document ?? throw new ArgumentNullException(nameof(document)); + this.isDatabaseMode = isDatabaseMode; + this.serverUrl = serverUrl; + this.ledgerSettings = ledgerSettings; + this.createClient = createClient; + } + + /// Raised on the UI thread when or changes. + public event EventHandler Changed; + + /// + /// Raised after the nest is marked Has Been Cut while open holds remain; the argument is + /// how many. Informational only: nothing waits on it. + /// + public event EventHandler CloseoutNoteRequested; + + /// The last answer from the ledger; null when not applicable or not read yet. + public LedgerHoldSummary Summary { get; private set; } + + public bool IsRefreshing => current != null; + + /// A saved server nest in Database mode with a ledger configured on this PC. + public bool IsApplicable => + isDatabaseMode() && document.RemoteId != Guid.Empty && !document.IsFirstDatabaseSave(serverUrl()) + && ledgerSettings().IsConfigured; + + /// Whether a status line belongs on screen: applicable and read or being read. + public bool IsVisible => IsApplicable && (Summary != null || IsRefreshing); + + /// The nest's page in MaterialLedger, where its holds are closed out. + public string LedgerNestUrl => $"{ledgerSettings().BaseUrl.TrimEnd('/')}/Nests/{document.RemoteId:D}"; + + /// + /// Rereads the holds after the nest was opened or saved, counting the sheets of the nest + /// as it now is on the server. + /// + public Task RefreshAfterSaveAsync() + { + if (document.Nest != null) + savedSheets = LedgerHoldSummary.PhysicalSheetCount(document.Nest); + return RefreshAsync(); + } + + /// + /// Rereads the holds against the sheet count of the last open or save. Never throws; a + /// ledger failure becomes an unavailable summary. + /// + public async Task RefreshAsync() + { + current?.Cancel(); + if (!IsApplicable) + { + current = null; + Summary = null; + OnChanged(); + return; + } + + using var cancel = new CancellationTokenSource(); + current = cancel; + OnChanged(); + LedgerHoldSummary summary; + try + { + var result = await createClient(ledgerSettings()).GetHoldsAsync(document.RemoteId, cancel.Token); + summary = LedgerHoldSummary.From(result, savedSheets); + } + catch (OperationCanceledException) + { + return; // A newer refresh or Dispose took over. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + summary = LedgerHoldSummary.From(new LedgerHoldsResult.Unreachable(ex.Message), savedSheets); + } + + if (!ReferenceEquals(current, cancel)) + return; + current = null; + Summary = summary; + OnChanged(); + } + + /// + /// Called when the nest's status changes to Has Been Cut: rereads the holds and raises + /// when any are still open. + /// + public async Task NoteCutAsync() + { + if (!IsApplicable) + return; + await RefreshAsync(); + if (Summary is { State: not LedgerHoldState.Unavailable, OpenHolds: > 0 } summary) + CloseoutNoteRequested?.Invoke(this, summary.OpenHolds); + } + + public void Dispose() + { + current?.Cancel(); + current = null; + Changed = null; + CloseoutNoteRequested = null; + } + + private void OnChanged() => Changed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index c6fec3bc..523f6313 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -330,3 +330,6 @@ with its own benchmark. MCP, Console and `OpenNest.Api` do not load ledger stock yet. The client and mapper live in `OpenNest.Data`, so these front ends can use them. + +The status bar shows a server nest's ledger holds and reminds you to close them out once +the nest is cut; see [MaterialLedger holds](nest-storage.md#materialledger-holds-desktop). diff --git a/docs/nest-storage.md b/docs/nest-storage.md index cd9c2f5a..f4224bc2 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -149,6 +149,46 @@ desktop wrote at save (see [expected remnants](nest-file-format.md#expected-remn A file without it, or with a malformed or non-positive value or an unknown source, reports `null`. +### MaterialLedger holds (desktop) + +MaterialLedger creates one hold per physical sheet of a server nest, from +`/sheets`, and stores the nest's ID. The desktop shows those holds read-only; it +never creates, approves or closes them, and nothing waits on the ledger. + +**Where.** A status bar label after the material label, only for a nest saved to +the server in Database mode (`Document.RemoteId`, not a first save) while +Tools > Options > MaterialLedger has a URL ([setup](automatic-nesting.md#stock-from-materialledger)). +File-mode nests, unsaved nests and PCs without a ledger show nothing. The tooltip lists +each hold: item code, size in the nest's units, location code and status. + +**When.** The holds are read when a server nest opens, after each Save, after the +storage mode or Options change, and when you click the label. The status bar never polls. +Each read uses `MaterialLedgerClient.GetHoldsAsync` (`GET /api/stock/holds?openNestNestId=`, +`X-Api-Key`, 5 s), whose result is `NotConfigured`, `Unreachable(reason)`, +`Unauthorized` or `Ok(holds)`. A newer read replaces an older one still running. + +**Summary.** `LedgerHoldSummary` (`OpenNest.Data/MaterialLedger/`) compares the +open holds with the sheets of the last open or save: the sum of plate quantities, +skipping the trailing empty plate as `/sheets` does. Proposed, Approved, Released +and AwaitingCloseout holds are open. Closed, cancelled, exception and unknown +statuses are listed but not counted; unknown status strings are kept as sent. + +| Holds | Label | +|-------|-------| +| One open hold per sheet | `Ledger: 4 of 4 sheets held` | +| More open holds than sheets | `Ledger: 4 of 4 sheets held, 1 extra` | +| Some sheets without one | `Ledger: 2 of 4 held, 2 short` | +| None open | `Ledger: no holds`, or `Ledger: no open holds (3 closed)` | +| No answer or key rejected | `Ledger: unavailable` (the tooltip gives the reason) | + +The label prefix is `Ledger:` rather than `Material:` so it is not confused with the +nest's material label beside it. + +**Has Been Cut.** When Nest Info changes the status to Has Been Cut, the desktop +rereads the holds. If any are still open, it shows a non-modal note, "Close out N holds +in MaterialLedger", with a link to `{ledgerUrl}/Nests/{id}`. Closeout happens there. +The note never blocks the dialog, save or post, and posting is not gated on holds. + ### Generated nest names **File > New** names a nest `N-` (`OpenNest.Data.NestNames`): the