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