feat(desktop): show a server nest's MaterialLedger holds and closeout note

Server nests in Database mode get a read-only status bar label comparing their
open MaterialLedger holds with the sheets they cut, e.g. "Ledger: 2 of 4 held,
2 short". Nothing writes to the ledger and nothing waits on it.

- MaterialLedgerClient.GetHoldsAsync reads GET /api/stock/holds?openNestNestId=
  with the closed LedgerHoldsResult set; unknown status strings are kept. The
  request plumbing is shared with GetStockAsync.
- LedgerHoldSummary (OpenNest.Data) counts Proposed/Approved/Released/
  AwaitingCloseout holds against the plate quantities, skipping the trailing
  empty plate as /sheets does.
- NestHoldStatus refreshes on server open, after save, after storage or
  Options changes and on label click; it never polls. The tooltip lists holds
  in nest units. File-mode, unsaved and ledger-less nests show nothing.
- Setting Has Been Cut in Nest Info with holds still open shows a non-modal
  note linking to {ledgerUrl}/Nests/{id}. Posting is not gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 17:40:45 -04:00
1 parent 23f093fcc1
commit f847b216f2
17 files changed
+1020 -24

No files matched your search

@@ -0,0 +1,160 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.Data.MaterialLedger;
/// <summary>How a nest's open ledger holds compare with the sheets it cuts.</summary>
public enum LedgerHoldState
{
/// <summary>Every sheet has an open hold (possibly more holds than sheets).</summary>
Held,
/// <summary>Some sheets have an open hold and some do not.</summary>
Short,
/// <summary>No open holds, whether or not closed ones exist.</summary>
None,
/// <summary>The ledger did not answer, or rejected the API key.</summary>
Unavailable,
}
/// <summary>
/// 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
/// (<see cref="LedgerHoldStatuses.IsOpen"/>) count as held.
/// </summary>
public sealed class LedgerHoldSummary
{
private const int MaxTooltipHolds = 20;
private const double MillimetersPerInch = 25.4;
private LedgerHoldSummary(LedgerHoldState state, int sheets, IReadOnlyList<LedgerHold> 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; }
/// <summary>The nest's physical sheets (<see cref="PhysicalSheetCount"/>) when it was summarized.</summary>
public int Sheets { get; }
/// <summary>Every hold the ledger returned, open or not, in its order.</summary>
public IReadOnlyList<LedgerHold> Holds { get; }
public int OpenHolds { get; }
/// <summary>Holds in any other status: closed, cancelled, exception or unknown.</summary>
public int ClosedHolds { get; }
/// <summary>Sheets without an open hold.</summary>
public int ShortSheets => System.Math.Max(0, Sheets - OpenHolds);
/// <summary>Why the ledger is unavailable; null otherwise.</summary>
public string? Reason { get; }
/// <summary>The status line, e.g. "Ledger: 2 of 4 held, 2 short".</summary>
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",
};
/// <summary>
/// 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 <c>/sheets</c> does (it is the editor's
/// new-plate slot). This is what MaterialLedger makes one hold per sheet from.
/// </summary>
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;
}
/// <summary>
/// Summarizes <paramref name="result"/> against <paramref name="sheets"/>; null when the
/// ledger is not configured, so callers show nothing.
/// </summary>
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<LedgerHold>(),
"MaterialLedger rejected the API key. Check it in Options.");
case LedgerHoldsResult.Unreachable unreachable:
return new(LedgerHoldState.Unavailable, sheets, Array.Empty<LedgerHold>(), 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);
}
}
/// <summary>The status line, then one line per hold with its size in <paramref name="units"/>.</summary>
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();
}
/// <summary>One hold, e.g. "DROP-1182 30 x 52.5 in B3 Approved".</summary>
public static string FormatHold(LedgerHold hold, Units units)
{
ArgumentNullException.ThrowIfNull(hold);
var parts = new List<string> { 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);
}
/// <summary>A ledger size (inches) in nest units, e.g. "30 x 52.5 in" or "762 x 1333.5 mm".</summary>
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);
}
/// <summary>An ML status name spaced for display ("AwaitingCloseout" → "Awaiting Closeout").</summary>
public static string DisplayStatus(string? status) =>
string.IsNullOrWhiteSpace(status) ? "(no status)" : Regex.Replace(status.Trim(), "(?<=[a-z])(?=[A-Z])", " ");
}
@@ -0,0 +1,59 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>The body of MaterialLedger's <c>GET /api/stock/holds?openNestNestId=</c>.</summary>
public sealed record LedgerHoldsResponse(Guid OpenNestNestId, IReadOnlyList<LedgerHold>? Holds);
/// <summary>
/// One ML <c>MaterialReservation</c> for a sheet of the nest. Sizes are inches with
/// <see cref="WidthIn"/> the short side. <see cref="Status"/> stays a string so a new ML
/// <c>ReservationStatus</c> value still deserializes.
/// </summary>
public sealed record LedgerHold(
long MaterialReservationId,
string ItemCode,
decimal WidthIn,
decimal LengthIn,
string Status,
string? LocationCode,
DateTime CreatedAtUtc);
/// <summary>ML <c>ReservationStatus</c> names.</summary>
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";
/// <summary>
/// True for holds that still keep a sheet for the nest and need closing out. Closed,
/// cancelled, exception and unknown statuses are not open.
/// </summary>
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);
}
/// <summary>What a ledger hold request came to. Expected failures are results, never exceptions.</summary>
public abstract record LedgerHoldsResult
{
private LedgerHoldsResult() { }
/// <summary>No ledger URL is set on this PC.</summary>
public sealed record NotConfigured : LedgerHoldsResult;
/// <summary>The ledger did not answer usefully: network error, timeout, bad status or body.</summary>
public sealed record Unreachable(string Reason) : LedgerHoldsResult;
/// <summary>The ledger rejected the API key (401/403).</summary>
public sealed record Unauthorized : LedgerHoldsResult;
/// <summary>The nest's holds in every status; empty when ML has none for it.</summary>
public sealed record Ok(IReadOnlyList<LedgerHold> Holds) : LedgerHoldsResult;
}
@@ -6,9 +6,10 @@ using System.Text.Json;
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// 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
/// <see cref="LedgerStockResult"/> 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
/// (<see cref="LedgerStockResult"/>, <see cref="LedgerHoldsResult"/>); only the caller's own
/// cancellation throws.
/// </summary>
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<LedgerStockResponse>(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<LedgerStockSpec>(),
stock.Items ?? Array.Empty<LedgerStockItem>());
}
/// <summary>
/// Every hold ML has for the sheets of server nest <paramref name="openNestNestId"/>, in
/// whatever status; the caller decides which are open.
/// </summary>
public async Task<LedgerHoldsResult> 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<LedgerHoldsResponse>(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<LedgerHold>())
.Where(hold => hold != null)
.Select(hold => hold with { ItemCode = hold.ItemCode ?? "", Status = hold.Status ?? "" })
.ToList());
}
/// <summary>
/// GETs <paramref name="pathAndQuery"/> below the ledger's base URL with the API key and the
/// per-request timeout. <paramref name="route"/> names the route in the 404 message.
/// </summary>
private async Task<Reply<T>> GetJsonAsync<T>(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<T>.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<T>(null, true, null);
if (response.StatusCode == HttpStatusCode.NotFound)
return new LedgerStockResult.Unreachable("This MaterialLedger has no OpenNest stock route; update MaterialLedger.");
return Reply<T>.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<T>.Failed($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd());
await using var body = await response.Content.ReadAsStreamAsync(timeoutSource.Token);
var stock = await JsonSerializer.DeserializeAsync<LedgerStockResponse>(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<LedgerStockSpec>(),
stock.Items ?? Array.Empty<LedgerStockItem>());
var value = await JsonSerializer.DeserializeAsync<T>(body, JsonOptions, timeoutSource.Token);
return value == null
? Reply<T>.Failed("The ledger returned an empty response.")
: new Reply<T>(value, false, null);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new LedgerStockResult.Unreachable(
return Reply<T>.Failed(
$"No answer within {timeout.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} s.");
}
catch (HttpRequestException ex)
{
return new LedgerStockResult.Unreachable(ex.Message);
return Reply<T>.Failed(ex.Message);
}
catch (JsonException)
{
return new LedgerStockResult.Unreachable("The ledger's response could not be read.");
return Reply<T>.Failed("The ledger's response could not be read.");
}
catch (IOException ex)
{
return new LedgerStockResult.Unreachable(ex.Message);
return Reply<T>.Failed(ex.Message);
}
}
/// <summary>A parsed body, a rejected key, or why the ledger was unreachable.</summary>
private readonly record struct Reply<T>(T? Body, bool Unauthorized, string? Failure) where T : class
{
public static Reply<T> Failed(string reason) => new(null, false, reason);
}
}