Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ be read at once.
**Left to grade (#134)** — grading counts **down** to zero rather than reporting
*graded n of m*. The figure beside the grades is how many sellable eggs are
still unaccounted for; it turns green when the day adds up exactly and red when
the grades overshoot the sellable count. Submitting is blocked while it is over.
the grades overshoot the sellable count. Submitting is blocked unless it reads
**exactly zero** (#394) — short of sellable is refused just as much as over.

**Entry footer (#134)** — both saves sit in a bar pinned to the bottom of the
screen, along with save messages: anything below a pinned bar scrolls underneath
Expand Down Expand Up @@ -126,12 +127,16 @@ from starting a fresh day. Only locked days carried a signal before.
older than 7 farm-local days (spec §8.1 default; background sweep).
Locked ≠ untouchable: admins can still adjust or void.
- **ManagerAdjusted** — an admin corrected the totals/grades of a
submitted or locked entry (reason required). The correction reconciles
the entry's egg lots in the same transaction — grown, shrunk, added, or
emptied — but **never below what a lot already sold**, and appends a
compensating bird movement for any mortality change. The replaced values
are kept as an audit snapshot on the entry until the audit log lands.
Adjusting again is allowed; each adjust snapshots what it replaced.
submitted or locked entry (reason required). An adjustment has no draft
state of its own, so it is held to the same **exact reconciliation** as
submit (#394): the corrected grades must sum to exactly the corrected
sellable count, with no partial-save escape hatch. The correction
reconciles the entry's egg lots in the same transaction — grown, shrunk,
added, or emptied — but **never below what a lot already sold**, and
appends a compensating bird movement for any mortality change. The
replaced values are kept as an audit snapshot on the entry until the audit
log lands. Adjusting again is allowed; each adjust snapshots what it
replaced.
- **Voided** — an admin undid the whole entry (reason required): every lot
it generated is emptied (refused if any of its eggs were sold), the
day's mortality is reversed by a compensating movement, and the entry is
Expand All @@ -144,7 +149,19 @@ from starting a fresh day. Only locked days carried a signal before.

**Sellable cap** — graded quantities must fit in
`total − cracked − dirty − discarded`. You cannot grade more eggs than
survived the day.
survived the day. This is the only rule a **Draft** enforces — a draft may be
graded partially, or not at all, and still be saved.

**Grade reconciliation (#394)** — **Submit**, and **saving an adjustment**
(which has no draft state of its own to leave incomplete), both go further
than the sellable cap above: the grade lines must sum to *exactly*
`total − cracked − dirty − discarded`, not merely fit within it. Zero
sellable eggs validly reconciles to zero grade lines — a day where every
egg was lost needs no grading to submit. Short of sellable and over
sellable are refused the same way, at both the domain and the API layer,
so a direct API caller cannot bypass it any more than the SPA can. This
closes the gap where an ungraded, no-loss day could submit cleanly and
silently produce zero stock for real production.

## Grading & stock

Expand Down
74 changes: 60 additions & 14 deletions src/Cluckwork.Domain/Eggs/DailyEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public Result RecordProduction(

// null = leave existing grade lines untouched (older clients omit the
// field); the kept lines must still fit the new totals. [] clears.
// Drafts stay flexible (#394): grading may be incomplete or absent right
// up until submit, so this is the lenient "cannot exceed" check only.
var effectiveGrades = grades ?? CurrentGradeQuantities();
var gradeResult = ValidateGrades(totalEggs, cracked, dirty, discarded, effectiveGrades);
if (gradeResult.IsFailure) return gradeResult;
Expand All @@ -84,6 +86,18 @@ public Result Submit()
if (Status != DailyEntryStatus.Draft)
return Result.Failure(Error.Domain(
"DailyEntry.NotDraft", "Only draft entries can be submitted."));

// #394 — a draft may be incomplete or entirely ungraded, but submitting
// freezes it and turns the grade lines into the day's only stock: an
// ungraded submit would silently produce zero lots for real production.
// Grades must reconcile EXACTLY to the sellable count here (zero sellable
// reconciles to zero lines), unlike RecordProduction's lenient "cannot
// exceed" check above.
var gradeResult = ValidateGrades(
TotalEggs, CrackedEggs, DirtyEggs, DiscardedEggs, CurrentGradeQuantities(),
requireExactReconciliation: true);
if (gradeResult.IsFailure) return gradeResult;

Status = DailyEntryStatus.Submitted;
// Version is a concurrency token, not auto-incremented by EF: without this
// bump, two racing submits both match WHERE Version = N and both succeed —
Expand Down Expand Up @@ -130,8 +144,13 @@ public Result ManagerAdjust(
return Result.Failure(Error.Validation(
"DailyEntry.ReasonTooLong", $"Reason cannot exceed {MaxReasonLength} characters."));

// #394 — an adjustment has no draft state of its own to leave
// incomplete: it replaces the entry's official numbers outright, so it
// is held to the same exact reconciliation as submit.
var effectiveGrades = grades ?? CurrentGradeQuantities();
var gradeResult = ValidateGrades(totalEggs, cracked, dirty, discarded, effectiveGrades);
var gradeResult = ValidateGrades(
totalEggs, cracked, dirty, discarded, effectiveGrades,
requireExactReconciliation: true);
if (gradeResult.IsFailure) return gradeResult;

AdjustedFromJson = SnapshotJson();
Expand Down Expand Up @@ -189,28 +208,55 @@ private string SnapshotJson() => JsonSerializer.Serialize(new
private List<GradeQuantity> CurrentGradeQuantities() =>
_grades.Select(l => new GradeQuantity(l.EggGradeId, l.Quantity)).ToList();

// requireExactReconciliation=false (RecordProduction/draft): grades may be
// incomplete or absent — only "cannot exceed the sellable count" is
// enforced, and an empty set trivially passes regardless of sellable.
// requireExactReconciliation=true (Submit/ManagerAdjust, #394): grades must
// sum to EXACTLY the sellable count — an empty set only passes when
// sellable is itself zero, so an ungraded non-zero day is refused rather
// than silently producing no stock.
private static Result ValidateGrades(
int totalEggs, int cracked, int dirty, int discarded,
IReadOnlyCollection<GradeQuantity> grades)
IReadOnlyCollection<GradeQuantity> grades,
bool requireExactReconciliation = false)
{
if (grades.Count == 0) return Result.Success();

if (grades.Any(g => g.EggGradeId == Guid.Empty))
return Result.Failure(Error.Validation(
"DailyEntry.InvalidGrade", "Egg grade id is required."));
if (grades.Count > 0)
{
if (grades.Any(g => g.EggGradeId == Guid.Empty))
return Result.Failure(Error.Validation(
"DailyEntry.InvalidGrade", "Egg grade id is required."));

if (grades.Any(g => g.Quantity <= 0))
return Result.Failure(Error.Validation(
"DailyEntry.InvalidGrade", "Grade quantities must be positive."));
if (grades.Any(g => g.Quantity <= 0))
return Result.Failure(Error.Validation(
"DailyEntry.InvalidGrade", "Grade quantities must be positive."));

if (grades.Select(g => g.EggGradeId).Distinct().Count() != grades.Count)
return Result.Failure(Error.Validation(
"DailyEntry.DuplicateGrade", "Each grade may appear only once."));
if (grades.Select(g => g.EggGradeId).Distinct().Count() != grades.Count)
return Result.Failure(Error.Validation(
"DailyEntry.DuplicateGrade", "Each grade may appear only once."));
}
else if (!requireExactReconciliation)
{
// Lenient path only: zero lines is always a valid (incomplete) draft,
// regardless of what the counts imply. The exact path falls through —
// zero sellable is the one case where zero lines is also correct there.
return Result.Success();
}

// Grades are the sellable portion; cracked/dirty/discarded are losses out
// of the same total. long accumulation — Sum<int> would throw on overflow.
var sellable = (long)totalEggs - cracked - dirty - discarded;
if (grades.Sum(g => (long)g.Quantity) > sellable)
var gradedTotal = grades.Sum(g => (long)g.Quantity);

if (requireExactReconciliation)
{
if (gradedTotal != sellable)
return Result.Failure(Error.Domain(
"DailyEntry.GradesNotReconciled",
"Graded quantities must equal total eggs minus cracked, dirty, and discarded eggs."));
return Result.Success();
}

if (gradedTotal > sellable)
return Result.Failure(Error.Domain(
"DailyEntry.GradesExceedTotal",
"Graded quantities cannot exceed total eggs minus cracked/dirty/discarded."));
Expand Down
3 changes: 2 additions & 1 deletion tests/Cluckwork.Api.IntegrationTests/AdminGatingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public async Task Worker_RunsTheFullDailyLoop()
var productId = await factory.SeedProductAsync(accountId, farmId, grades["Large"], "Large Eggs");

// Entry → submit (creates the day's egg lots).
// #394: submit requires exact reconciliation — total matches the grade.
var entry = await worker.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId,
Expand All @@ -135,7 +136,7 @@ public async Task Worker_RunsTheFullDailyLoop()
dirtyEggs = 0,
discardedEggs = 0,
mortalityCount = 0,
grades = new[] { new { eggGradeId = grades["Large"], quantity = 90 } }
grades = new[] { new { eggGradeId = grades["Large"], quantity = 100 } }
});
Assert.Equal(HttpStatusCode.Created, entry.StatusCode);
var entryId = (await entry.Content.ReadFromJsonAsync<Created>())!.Id;
Expand Down
13 changes: 8 additions & 5 deletions tests/Cluckwork.Api.IntegrationTests/AuditTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ public async Task Adjust_WritesEvent_WithActorAndReason_FailedAdjustWritesNothin
{
var (client, email, _, farmId, flockId, gradeId) = await SetupAsync();

// #394: submit requires exact reconciliation — total matches the grade.
var record = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = Today,
totalEggs = 100, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
totalEggs = 90, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
mortalityCount = 0,
grades = new[] { new { eggGradeId = gradeId, quantity = 90 } }
});
Expand Down Expand Up @@ -118,11 +119,12 @@ public async Task Viewer_NeverCrossesTenants()
{
var (clientA, _, _, farmA, flockA, gradeA) = await SetupAsync();

// Tenant A produces an audited action.
// Tenant A produces an audited action. #394: both submit and adjust
// require exact reconciliation — total matches the grade throughout.
var record = await clientA.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId = farmA, houseId = Guid.NewGuid(), flockId = flockA, date = Today,
totalEggs = 50, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
totalEggs = 40, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
mortalityCount = 0,
grades = new[] { new { eggGradeId = gradeA, quantity = 40 } }
});
Expand All @@ -131,7 +133,7 @@ public async Task Viewer_NeverCrossesTenants()
var version = (await clientA.GetFromJsonAsync<EntryDto>($"/api/v1/daily-entries/{entryId}"))!.Version;
await clientA.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/adjust", Guid.NewGuid().ToString(), new
{
version, totalEggs = 45, crackedEggs = 0, dirtyEggs = 0,
version, totalEggs = 40, crackedEggs = 0, dirtyEggs = 0,
discardedEggs = 0, mortalityCount = 0, reason = "tenant A only"
});
Assert.Single((await clientA.GetFromJsonAsync<List<AuditRow>>($"/api/v1/audit?entityId={entryId}"))!);
Expand Down Expand Up @@ -201,10 +203,11 @@ public async Task MidTransactionFailure_WritesNothing_EventualVoidWritesOne()
var (client, _, accountId, farmId, flockId, gradeId) = await SetupAsync();
var productId = await factory.SeedProductAsync(accountId, farmId, gradeId);

// #394: submit requires exact reconciliation — total matches the grade.
var record = await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = Today,
totalEggs = 100, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
totalEggs = 90, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0,
mortalityCount = 0,
grades = new[] { new { eggGradeId = gradeId, quantity = 90 } }
});
Expand Down
6 changes: 5 additions & 1 deletion tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ private sealed record MovementDto(
return (client, accountId, id, flock!);
}

// #394: submit requires grades to reconcile exactly to the sellable count.
// This file is about the bird ledger, not grading, so every entry here is
// a zero-sellable day (discardedEggs matches totalEggs) — zero grade
// lines then reconciles trivially, with no grade catalog needed at all.
private static object EntryBody(FlockDto f, DateOnly date, int mortality) => new
{
farmId = f.FarmId,
Expand All @@ -38,7 +42,7 @@ private sealed record MovementDto(
totalEggs = 50,
crackedEggs = 0,
dirtyEggs = 0,
discardedEggs = 0,
discardedEggs = 50,
mortalityCount = mortality,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,13 @@ public async Task FullLoop_PureHttp_RecordToSale()
var today = DateOnly.FromDateTime(DateTime.UtcNow.Date);

// 1. record + submit production
// #394: submit requires exact reconciliation — 918 total − 10 cracked −
// 5 dirty − 3 discarded = 900 sellable, matching the two grade lines.
var entryId = await CreatedId(await client.PostWithKeyAsync(
"/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = today,
totalEggs = 1000, crackedEggs = 10, dirtyEggs = 5, discardedEggs = 3, mortalityCount = 0,
totalEggs = 918, crackedEggs = 10, dirtyEggs = 5, discardedEggs = 3, mortalityCount = 0,
grades = new[]
{
new { eggGradeId = grades["Large"], quantity = 600 },
Expand Down
Loading