Skip to content

F17 (part 1): Auto-lock + admin adjust/void of submitted entries with lot & ledger reconciliation (#69) - #80

Merged
mforce merged 3 commits into
mainfrom
feat/f17-entry-adjust
Jul 19, 2026
Merged

mforce merged 3 commits into
mainfrom
feat/f17-entry-adjust

Conversation

@mforce

@mforce mforce commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Part 1 of #69 (epic #14) — the backend. Part 2 (History-page UI + Help/GLOSSARY docs, per the F15 two-PR precedent) follows on top of this.

What ships

Auto-lock (spec §8.1 default) — submitted entries older than 7 farm-local days (account timezone; #35 refines) lock automatically. The sweep runs per-account inside the DurableJobWorker's guarded poll (#65 landed first for exactly this reason), stamps lockedAtUtc, is idempotent, and loses Version races to concurrent admin work gracefully.

Admin adjust (POST /daily-entries/{id}/adjust, AdminOnly per #73) — one path for Submitted AND Locked (and re-adjusting ManagerAdjusted): totals + grade lines + required reason, client base version (stale → deterministic 409; EF token backstops). In ONE transaction:

  • Egg lots reconciled under FOR UPDATE in the canonical (ProductionDate, Id) order shared with confirm/void (no deadlock against sales): grown, shrunk, added (new grade line → new linked lot), or emptied in place (line removed).
  • Sold is untouchable: shrinking a grade below what its lot already sold/allocated → 422 naming the grade, full rollback.
  • Bird ledger compensated: mortality delta lands as a NEW row tied to the entry (more deaths → Mortality; fewer → negative Adjustment). Originals never edited.
  • Audit until the audit-log slice: reason + a JSON snapshot of the replaced values (adjustedFrom) persisted and returned on the entry.

Void (POST /daily-entries/{id}/void, AdminOnly) — spec §8.1/§9.5: empties every lot the entry generated (422 if any eggs were sold), reverses the day's mortality, preserves the entry as Voided with its reason.

The design find (live verification caught it)

First cut reconciled lots by (flock, date) — and promptly zeroed a sibling entry's lot in the dev DB: multiple houses mean multiple entries per flock/day, so (flock, date) is not an entry surface. Fix: EggLot.DailyEntryId — every submit/adjust-created lot links to its entry; the migration backfills history where exactly one non-draft entry matches; unprovable lots stay null and their entries refuse adjust/void with DailyEntry.PredatesLotTracking (same honesty as pre-#60 orders refusing void). Regression-tested with sibling entries.

Contracts

Tests — 222 green (was 202)

Reconciliation matrix (grow/shrink/add/remove + snapshot), shrink-below-sold 422 with rollback assertions, mortality compensation both directions (CurrentBirds verified), void + void-with-sold-stock, sibling-entry isolation, parallel adjusts (exactly one wins, Version delta 1 per AGENTS.md), draft/stale/blank-reason guards, lock-sweep (old locks, recent doesn't, locked entry still adjustable), worker-403 matrix extended with both routes. Domain: 11 new unit tests (adjust states, snapshots, void transitions, AdjustProduction).

Live-verified

8-day-old entry auto-locked by the sweep within one poll → adjust on the locked entry 200 (status ManagerAdjusted, reason + adjustedFrom.totalEggs present) → stale-version adjust 409 → void 200 (Voided + reason, stock returned). Migration + backfill applied cleanly against the dev DB with demo data.

mforce added 2 commits July 18, 2026 21:05
… lot and ledger reconciliation (#69)

Domain:
- ManagerAdjust now accepts Submitted, Locked, and ManagerAdjusted entries
  (spec §8.1 MVP: one adjust path pre- and post-lock), requires a reason,
  and snapshots the values it replaces into AdjustedFromJson (the audit
  trail until the audit-log slice). Void(reason) preserves the entry as
  Voided. Lock(at) stamps LockedAtUtc. EggLot.AdjustProduction re-states a
  lot: the sold/allocated portion is untouchable, available absorbs the
  delta, 0 empties an unsold lot in place.
- EggLot.DailyEntryId links every lot to the entry whose submit generated
  it. (flock, date) is NOT an entry surface — multiple houses mean sibling
  entries per flock and day, and live verification caught an adjust zeroing
  a sibling entry lot. Migration backfills the link where exactly one
  non-draft entry matches; unprovable lots stay null and their entries
  refuse adjust/void (DailyEntry.PredatesLotTracking) rather than guess.

Application:
- AdjustDailyEntryHandler: client base Version (409 on mismatch, EF token
  backstop), flock lifecycle gate, grade validation (active+saleable for
  new ids; ids already on the entry grandfathered), then in ONE transaction:
  FOR UPDATE on the entry lots in the canonical (ProductionDate, Id)
  order shared with confirm/void, lots grown/shrunk/added/emptied to match
  the new lines — shrinking below a lot sold quantity is a 422 naming the
  grade — and a compensating bird movement for any mortality delta (more
  deaths -> Mortality row; fewer -> negative Adjustment; originals never
  edited).
- VoidDailyEntryHandler: same shape; empties every linked lot (422 if any
  eggs were sold), appends the reversing movement, preserves the entry.
- DailyEntryLockSweep: submitted entries older than 7 farm-local days
  (account timezone) lock automatically; runs per-account inside the
  DurableJobWorker guarded poll; idempotent and Version-race safe.

API: POST /daily-entries/{id}/adjust and /void, both AdminOnly (#73);
entry responses now carry version, adjustReason, voidReason, lockedAtUtc,
and the adjustedFrom snapshot.

Tests (222 green): lot reconciliation matrix (grow/shrink/add/remove),
shrink-below-sold 422 with full rollback, mortality compensation both
directions with CurrentBirds assertions, void paths, sibling-entry
isolation (the live-verify regression), parallel adjusts (exactly one
wins, Version delta 1), draft/stale/blank-reason guards, lock sweep, and
the worker-403 matrix extended with both new routes.
- Duplicate-grade lots: every lot keeps its sold floor and the first in
  canonical order carries the remainder — a later duplicate holding sold
  eggs no longer produces a spurious 422 when the aggregate target covers
  all sold eggs. Explicit aggregate-level 422 when it does not.
- Ledger notes truncate to BirdMovement.MaxNoteLength: a max-length reason
  no longer throws (500) when prefixed into the compensating movement note.
- Lock sweep uses a strict comparison — an entry exactly 7 farm-local days
  old keeps its final editable day; only strictly older entries lock.
- Loss-count sum validated as long in BOTH the adjust and record
  validators: three ints could overflow past a positive total and slip
  through the domain guard when no grade lines were present.
- AdjustedFromJson is plain text, not jsonb (provider-portability rule);
  the two migrations squashed into one clean AddDailyEntryAdjustments with
  the unambiguous lot backfill.
- Docs-sync in THIS PR: Help page (submit bullet, 7-day lock bullet, two
  Fixing-mistakes rows, three glossary rows) + GLOSSARY.md full lifecycle
  rewrite (Locked/ManagerAdjusted/Voided, lot linkage, sold-untouchable).
- New race test: parallel adjust vs void on the same base version —
  exactly one wins, Version delta 1, no blended lot state.
@mforce

mforce commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

codex round applied (4 correctness P2s + docs P2 + both P3s):

Finding Fix
P2 Duplicate-grade lots: first-takes-all zeroed a duplicate holding sold eggs → spurious 422 Sold-floor distribution: every lot keeps its sold portion, first lot carries the remainder; explicit aggregate 422 otherwise
P2 Max-length reason + note prefix exceeded BirdMovement limit → 500 Notes truncate to the ledger limit; the stored reason is never cut
P2 Lock cutoff off-by-one (locked ON day 7) Strict < — the 7th day stays editable
P2 Loss-count int overflow slipped past validation when no grade lines present long accumulation in the adjust validator AND the pre-existing record validator (same bug)
P2 Docs not in same PR Help page + GLOSSARY updated here (lifecycle, 7-day lock, adjust/void mistake rows, glossary entries); part 2 only adds the UI controls
P3 jsonb violates provider portability Plain text column; migrations squashed into one clean AddDailyEntryAdjustments (re-applied to dev DB)
P3 Void lacks its own race test ParallelAdjustAndVoid_SameBaseVersion_ExactlyOneWins — one 200, one 409, Version delta 1, no blended lot state

223 tests green.

- Lock sweep isolates account failures: one account's bad timezone or
  transient DB error logs and moves on instead of starving the remaining
  accounts of their sweep. Shutdown cancellation still propagates.
- The success log reports the actually-locked count, not the batch size.
@mforce

mforce commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

pi round (local llamacpp, scoped) applied in the latest commit:

  • P2 one failing account aborted the sweep for all remaining accounts → per-account try/catch (cancellation still propagates; the worker guard remains the outer backstop).
  • P2 sweep success log claimed the batch size even if some Lock() calls failed → logs the actually-locked count.

Declined with rationale:

  • P3 LockedAtUtc = null on pre-existing Locked entries — the set is empty: Lock() existed but nothing ever called it before this PR, so no Locked entry predates the column.
  • P3 BirdMovement.Create throws on zero quantity — the established domain Create pattern across the codebase; both handlers guard the delta.
  • P3 sequential per-account sweep — single-farm MVP; revisit with multi-tenancy.

223 tests green. Part 2 (History-page adjust/void UI + badges) follows after this merges.

@mforce
mforce merged commit 9e5c29f into main Jul 19, 2026
3 checks passed
@mforce
mforce deleted the feat/f17-entry-adjust branch July 19, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant