mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 07:11:06 -04: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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<LedgerHoldsResult.Ok>(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<LedgerHoldsResult.Ok>(await client.GetHoldsAsync(NestId, default)).Holds);
|
||||
|
||||
var missing = Client(_ => Json("""{ "openNestNestId": "4f7c2a1e-0b9d-4e55-8a6f-1c2d3e4f5a6b" }"""));
|
||||
Assert.Empty(Assert.IsType<LedgerHoldsResult.Ok>(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<LedgerHoldsResult.Ok>(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<LedgerHoldsResult.NotConfigured>(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<LedgerHoldsResult.Unreachable>(
|
||||
await down.GetHoldsAsync(NestId, default)).Reason);
|
||||
|
||||
var oldLedger = Client(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
Assert.Contains("no OpenNest holds route", Assert.IsType<LedgerHoldsResult.Unreachable>(
|
||||
await oldLedger.GetHoldsAsync(NestId, default)).Reason);
|
||||
|
||||
var rejected = Client(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
Assert.IsType<LedgerHoldsResult.Unauthorized>(await rejected.GetHoldsAsync(NestId, default));
|
||||
|
||||
var garbage = Client(_ => Json("<html>login</html>"));
|
||||
Assert.IsType<LedgerHoldsResult.Unreachable>(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<LedgerHoldsResult.Unreachable>(
|
||||
await slow.GetHoldsAsync(NestId, default)).Reason);
|
||||
}
|
||||
|
||||
private static MaterialLedgerClient Client(Func<HttpRequestMessage, HttpResponseMessage> reply,
|
||||
MaterialLedgerSettings? settings = null) =>
|
||||
new(new HttpClient(new Handler(reply)), settings ?? Configured);
|
||||
|
||||
@@ -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<int>();
|
||||
open.CloseoutNoteRequested += (_, count) => noted.Add(count);
|
||||
await open.NoteCutAsync();
|
||||
Assert.Equal(new[] { 2 }, noted);
|
||||
|
||||
foreach (var reply in new Func<HttpRequestMessage, HttpResponseMessage>[]
|
||||
{
|
||||
_ => 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<HttpRequestMessage, HttpResponseMessage> 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<HttpRequestMessage, HttpResponseMessage> reply) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> 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.");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ namespace OpenNest.Forms
|
||||
|
||||
public Nest Nest => Document.Nest;
|
||||
|
||||
/// <summary>The nest's MaterialLedger holds; shown only for saved server nests.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
|
||||
/// <summary>Shows the note beside <paramref name="owner"/>'s lower right corner without taking over input.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,15 @@ namespace OpenNest.Forms
|
||||
editForm.WindowState = windowState;
|
||||
editForm.Show();
|
||||
editForm.PlateView.ZoomToFit();
|
||||
if (remoteId != Guid.Empty)
|
||||
_ = editForm.HoldStatus.RefreshAfterSaveAsync();
|
||||
}
|
||||
|
||||
/// <summary>Rereads every open nest's holds, e.g. after the storage mode or ledger changed.</summary>
|
||||
internal void RefreshLedgerHolds()
|
||||
{
|
||||
foreach (var form in MdiChildren.OfType<EditNestForm>())
|
||||
_ = form.HoldStatus.RefreshAfterSaveAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<EditNestForm>())
|
||||
form.UpdateDrawingLibraryCommand();
|
||||
RefreshLedgerHolds();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// <summary>The hidden-by-default hold label MainForm inserts after the material label.</summary>
|
||||
internal static ToolStripStatusLabel CreateLedgerLabel() => new()
|
||||
{
|
||||
Name = "ledgerStatusLabel",
|
||||
BorderSides = ToolStripStatusLabelBorderSides.Left,
|
||||
Padding = new Padding(5, 0, 5, 0),
|
||||
Visible = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Shows the active nest's hold summary only while it applies (a saved server nest with
|
||||
/// a ledger configured); the tooltip lists the holds.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Data.MaterialLedger;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Summary"/> 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.
|
||||
/// </summary>
|
||||
public sealed class NestHoldStatus : IDisposable
|
||||
{
|
||||
private readonly Document document;
|
||||
private readonly Func<bool> isDatabaseMode;
|
||||
private readonly Func<string> serverUrl;
|
||||
private readonly Func<MaterialLedgerSettings> ledgerSettings;
|
||||
private readonly Func<MaterialLedgerSettings, MaterialLedgerClient> 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<bool> isDatabaseMode, Func<string> serverUrl,
|
||||
Func<MaterialLedgerSettings> ledgerSettings, Func<MaterialLedgerSettings, MaterialLedgerClient> createClient)
|
||||
{
|
||||
this.document = document ?? throw new ArgumentNullException(nameof(document));
|
||||
this.isDatabaseMode = isDatabaseMode;
|
||||
this.serverUrl = serverUrl;
|
||||
this.ledgerSettings = ledgerSettings;
|
||||
this.createClient = createClient;
|
||||
}
|
||||
|
||||
/// <summary>Raised on the UI thread when <see cref="Summary"/> or <see cref="IsRefreshing"/> changes.</summary>
|
||||
public event EventHandler Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after the nest is marked Has Been Cut while open holds remain; the argument is
|
||||
/// how many. Informational only: nothing waits on it.
|
||||
/// </summary>
|
||||
public event EventHandler<int> CloseoutNoteRequested;
|
||||
|
||||
/// <summary>The last answer from the ledger; null when not applicable or not read yet.</summary>
|
||||
public LedgerHoldSummary Summary { get; private set; }
|
||||
|
||||
public bool IsRefreshing => current != null;
|
||||
|
||||
/// <summary>A saved server nest in Database mode with a ledger configured on this PC.</summary>
|
||||
public bool IsApplicable =>
|
||||
isDatabaseMode() && document.RemoteId != Guid.Empty && !document.IsFirstDatabaseSave(serverUrl())
|
||||
&& ledgerSettings().IsConfigured;
|
||||
|
||||
/// <summary>Whether a status line belongs on screen: applicable and read or being read.</summary>
|
||||
public bool IsVisible => IsApplicable && (Summary != null || IsRefreshing);
|
||||
|
||||
/// <summary>The nest's page in MaterialLedger, where its holds are closed out.</summary>
|
||||
public string LedgerNestUrl => $"{ledgerSettings().BaseUrl.TrimEnd('/')}/Nests/{document.RemoteId:D}";
|
||||
|
||||
/// <summary>
|
||||
/// Rereads the holds after the nest was opened or saved, counting the sheets of the nest
|
||||
/// as it now is on the server.
|
||||
/// </summary>
|
||||
public Task RefreshAfterSaveAsync()
|
||||
{
|
||||
if (document.Nest != null)
|
||||
savedSheets = LedgerHoldSummary.PhysicalSheetCount(document.Nest);
|
||||
return RefreshAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rereads the holds against the sheet count of the last open or save. Never throws; a
|
||||
/// ledger failure becomes an unavailable summary.
|
||||
/// </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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the nest's status changes to Has Been Cut: rereads the holds and raises
|
||||
/// <see cref="CloseoutNoteRequested"/> when any are still open.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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<yy>-<seq>` (`OpenNest.Data.NestNames`): the
|
||||
|
||||
Reference in new issue
Block a user