diff --git a/OpenNest.Core/NestStatus.cs b/OpenNest.Core/NestStatus.cs
index 7dbde27a..c9c54f42 100644
--- a/OpenNest.Core/NestStatus.cs
+++ b/OpenNest.Core/NestStatus.cs
@@ -4,10 +4,33 @@ namespace OpenNest
/// Shop-floor workflow state of a saved nest, persisted as a string in
/// nest.json so unknown future values can fall back safely.
///
+ ///
+ /// New values go at the end: the nest server still accepts integer statuses from
+ /// existing callers, so existing values must keep their numbers. Lists shown to the
+ /// operator use , not declaration order.
+ ///
public enum NestStatus
{
Quote,
ToBeCut,
HasBeenCut,
+
+ ///
+ /// Created but not yet ready to quote or cut, such as a nest released from a
+ /// drawing set before it has been nested and checked. Comes before Quote.
+ ///
+ Draft,
+ }
+
+ public static class NestStatuses
+ {
+ /// Every status in the order a nest moves through them.
+ public static readonly NestStatus[] WorkflowOrder =
+ {
+ NestStatus.Draft,
+ NestStatus.Quote,
+ NestStatus.ToBeCut,
+ NestStatus.HasBeenCut,
+ };
}
}
diff --git a/OpenNest.Server.Tests/NestUploadValidationTests.cs b/OpenNest.Server.Tests/NestUploadValidationTests.cs
index 4256fe02..3667680a 100644
--- a/OpenNest.Server.Tests/NestUploadValidationTests.cs
+++ b/OpenNest.Server.Tests/NestUploadValidationTests.cs
@@ -65,6 +65,7 @@ public sealed class NestUploadValidationTests
[InlineData("""{"name":"A","madeBy":null}""")]
[InlineData("""{"name":"A","status":99}""")]
[InlineData("""{"name":"A","status":"cancelled"}""")]
+ [InlineData("""{"name":"A","status":4}""")]
public async Task Create_WithInvalidMetadata_Returns400AndStoresNothing(string metadata)
{
using var factory = new ServerFactory();
@@ -80,6 +81,8 @@ public sealed class NestUploadValidationTests
[Theory]
[InlineData("""{"name":"A","status":"toBeCut"}""", NestStatus.ToBeCut)]
[InlineData("""{"name":"A","status":2}""", NestStatus.HasBeenCut)]
+ [InlineData("""{"name":"A","status":"draft"}""", NestStatus.Draft)]
+ [InlineData("""{"name":"A","status":3}""", NestStatus.Draft)]
public async Task Create_WithDefinedStatus_StoresIt(string metadata, NestStatus expected)
{
using var factory = new ServerFactory();
diff --git a/OpenNest.Server/NestMetadata.cs b/OpenNest.Server/NestMetadata.cs
index 9a7d53de..751a7ff3 100644
--- a/OpenNest.Server/NestMetadata.cs
+++ b/OpenNest.Server/NestMetadata.cs
@@ -53,7 +53,7 @@ internal static class NestMetadata
if (record.Material is null) return "material must be a string.";
if (record.Comments is null) return "comments must be a string.";
if (record.MadeBy is null) return "madeBy must be a string.";
- if (!Enum.IsDefined(record.Status)) return "status must be quote, toBeCut or hasBeenCut.";
+ if (!Enum.IsDefined(record.Status)) return "status must be draft, quote, toBeCut or hasBeenCut.";
if (!double.IsFinite(record.Thickness)) return "thickness must be a finite number.";
return null;
}
diff --git a/OpenNest.Tests/IO/NestStatusSerializationTests.cs b/OpenNest.Tests/IO/NestStatusSerializationTests.cs
index a1a7a9ed..eeaadd9a 100644
--- a/OpenNest.Tests/IO/NestStatusSerializationTests.cs
+++ b/OpenNest.Tests/IO/NestStatusSerializationTests.cs
@@ -43,6 +43,41 @@ public class NestStatusSerializationTests
Assert.Equal(NestStatus.HasBeenCut, loaded.Status);
}
+ [Fact]
+ public void RoundTrip_Draft_PreservedAsName()
+ {
+ var nest = CreateMinimalNest();
+ nest.Status = NestStatus.Draft;
+
+ using var ms = new MemoryStream();
+ new NestWriter(nest).Write(ms);
+ using var doc = JsonDocument.Parse(ReadNestJson(ms));
+ ms.Position = 0;
+ var loaded = new NestReader(ms).Read();
+
+ Assert.Equal("Draft", doc.RootElement.GetProperty("status").GetString());
+ Assert.Equal(NestStatus.Draft, loaded.Status);
+ }
+
+ [Fact]
+ public void WorkflowOrder_StartsWithDraftAndListsEveryStatusOnce()
+ {
+ Assert.Equal(NestStatus.Draft, NestStatuses.WorkflowOrder[0]);
+ Assert.Equal(
+ Enum.GetValues().OrderBy(s => s),
+ NestStatuses.WorkflowOrder.OrderBy(s => s));
+ }
+
+ [Fact]
+ public void ExistingStatusNumbers_AreUnchanged()
+ {
+ // The nest server still accepts integer statuses from existing callers.
+ Assert.Equal(0, (int)NestStatus.Quote);
+ Assert.Equal(1, (int)NestStatus.ToBeCut);
+ Assert.Equal(2, (int)NestStatus.HasBeenCut);
+ Assert.Equal(3, (int)NestStatus.Draft);
+ }
+
[Fact]
public void Status_SerializedAsEnumString()
{
diff --git a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
index bfd06286..be3235e2 100644
--- a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
@@ -143,6 +143,25 @@ public class EditNestInfoFormTests
});
}
+ [Fact]
+ public void StatusList_FollowsWorkflowOrder_AndSavesDraft()
+ {
+ RunSta(() =>
+ {
+ var nest = AcceptanceNest();
+ using var form = new EditNestInfoForm();
+ form.LoadNestInfo(nest);
+ var statusBox = Assert.IsType(form.Controls.Find("statusBox", true).Single());
+
+ Assert.Equal(NestStatuses.WorkflowOrder, statusBox.Items.Cast());
+
+ form.Status = NestStatus.Draft;
+ form.SaveNestInfo(nest);
+
+ Assert.Equal(NestStatus.Draft, nest.Status);
+ });
+ }
+
[Fact]
public void BlankName_DisablesOk()
{
diff --git a/OpenNest/Forms/EditNestInfoForm.cs b/OpenNest/Forms/EditNestInfoForm.cs
index bccc8ccd..26f450bd 100644
--- a/OpenNest/Forms/EditNestInfoForm.cs
+++ b/OpenNest/Forms/EditNestInfoForm.cs
@@ -19,7 +19,7 @@ namespace OpenNest.Forms
foreach (var name in PostProcessorMaterials.Names)
materialBox.Items.Add(name);
- foreach (var status in Enum.GetValues())
+ foreach (var status in NestStatuses.WorkflowOrder)
statusBox.Items.Add(status);
statusBox.SelectedItem = NestStatus.Quote;
diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs
index 09a353de..d7b6907e 100644
--- a/OpenNest/Forms/SavedNestsForm.cs
+++ b/OpenNest/Forms/SavedNestsForm.cs
@@ -542,6 +542,7 @@ public sealed class SavedNestsForm : Form
private static string FormatStatus(NestStatus status) => status switch
{
+ NestStatus.Draft => "Draft",
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 2b44ab32..77ee1011 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -8,9 +8,10 @@ opening and saving with an older application loses that state.
`nest.json` carries the workflow fields shown in the nest info dialog:
`name`, `customer`, `dateCreated`, `dateLastModified`, `notes`, `material`,
-`thickness`, plus `status` (`"Quote"`, `"ToBeCut"`, `"HasBeenCut"`) and `madeBy`.
+`thickness`, plus `status` (`"Draft"`, `"Quote"`, `"ToBeCut"`, `"HasBeenCut"`) and `madeBy`.
Status and made-by are additive: files written before them load as Quote with an
-empty maker, and an unrecognized status value likewise falls back to Quote.
+empty maker, and an unrecognized status value likewise falls back to Quote. Builds
+before `Draft` therefore open a Draft nest as Quote, and saving it there keeps Quote.
## Programs and placements
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index b746f836..d56e34c7 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -67,7 +67,9 @@ with enums serialized as strings (`JsonSerializerDefaults.Web` +
`JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`), matching the rest of
`OpenNest.Data`'s local JSON stores. Uploaded metadata (POST and both PUTs) must
carry string text fields (`null` is 400) and a defined `status`: a name, or for
-existing callers its integer value 0–2; an undefined value is 400 and stores nothing.
+existing callers its integer value (0 quote, 1 toBeCut, 2 hasBeenCut, 3 draft); an
+undefined value is 400 and stores nothing. A server older than `draft` refuses it,
+so upgrade the server before desktops that can set it.
| Field | Type | Notes |
|---|---|---|
@@ -77,7 +79,7 @@ existing callers its integer value 0–2; an undefined value is 400 and stores n
| `dateCreated` / `dateModified` | datetime | Set by the client from the nest's own metadata. |
| `material` | string | |
| `thickness` | number | |
-| `status` | `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
+| `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
| `plateCount` / `partCount` | integer | Computed client-side at save time (non-cutoff parts only). |
| `comments` | string | |
| `madeBy` | string | |