mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-05 05:51:34 -05:00
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:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user