diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Accounts.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Accounts.cs new file mode 100644 index 000000000..61c9cf75c --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Accounts.cs @@ -0,0 +1,75 @@ +using Cluckwork.Application.Modules.Farm.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Primary account timezone (BEFORE any dated data exists) ------- + + private async Task SeedPrimaryTimeZoneAsync(SimCast cast, SimulationOptions sim, CancellationToken ct) + { + // Farm settings are the Owner's to change (#500) — Account.UpdateSettings + // is audited. This ActAs is load-bearing, not decorative: the phase now + // runs directly after a MANAGER-authored one, so dropping it misattributes + // the row instead of doing nothing. + ActAs(cast.Owner); + + var account = await farm.GetSettingsAsync(ct) + ?? throw new InvalidOperationException("Simulation seed: the primary account was not found."); + + if (string.Equals(account.TimeZoneId, sim.TimeZoneId, StringComparison.Ordinal)) + return; // already set — idempotent re-run. + + var command = new UpdateFarmSettingsCommand( + account.Name, + sim.TimeZoneId, + account.Locale, + account.CurrencyCode, + account.UnitSystem.ToString(), + account.FirstDayOfWeek?.ToString(), + account.DateFormatOverride, + account.TimeFormatOverride, + account.Brand, + account.DefaultStepperUnit.ToString(), + account.WorkerSaleAllocationPolicy.ToString(), + account.Version, + // The command replaces the WHOLE settings block, so this phase must + // carry the farm's current ceiling forward or it would silently + // clear one (#727). The fixture sets none today; that is not a + // reason to hard-code null here. + account.MaxDiscountPercent); + + var result = await farm.UpdateSettingsAsync(command, ct); + Require(result, $"set primary account timezone to {sim.TimeZoneId}"); + } + + // --- Second, pristine account (tenant-isolation fixture) ----------- + + private async Task SeedSecondAccountAsync(CancellationToken ct) + { + // The account query filter would hide a second tenant's row — the + // fixture reads with it off, regardless of which tenant is resolved. + if (await farmFixture.AccountExistsAsync(SecondAccountId, ct)) return; + + // #546 — this row belongs to the SECOND account while this seeder's own + // scope is resolved to the PRIMARY one (see SeedAsync), so writing it + // through the seeder's own scope is a cross-tenant write and TenantStampInterceptor now + // refuses it. Give the write its own scope resolved to the account it + // actually writes, mirroring DailyEntryLockSweep.LockDueEntriesAsync. + // + // BOTH the TenantContext and the fixture must come from the new scope. + // The interceptor is bound to the scope that constructed its + // DbContext, so resolving the tenant here and still writing through the + // injected farmFixture would leave the guard reading the PRIMARY + // account — a no-op that looks like a fix. Resolving the injected + // `tenant` field instead would throw: it is single-assignment as of + // this same slice. + using var scope = scopeFactory.CreateScope(); + scope.ServiceProvider.GetRequiredService().Resolve(SecondAccountId); + await scope.ServiceProvider.GetRequiredService().CreateAccountAsync( + SecondAccountId, "Simulation Second Farm", "simulation-second-farm", "UTC", "USD", ct); + logger.LogInformation("Seeded second pristine simulation account {AccountId}.", SecondAccountId); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Cast.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Cast.cs new file mode 100644 index 000000000..9daf47a72 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Cast.cs @@ -0,0 +1,231 @@ +using Cluckwork.Application.Modules.Access.Contracts; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Cast: Managers, Sales, ReadOnly, Workers (role-less) --------- + + // #500 — one seeded persona, carrying everything the acting context needs. + // + // Roles is NOT a label. FlockScopeGuard reads it (via CurrentUserContext, + // which stores what ActAs passes it verbatim) and lets Owner/Manager through + // by role alone, so a SimActor built with the wrong roles changes what the + // fixture is ALLOWED to write, not merely whose name lands on the audit row. + private sealed record SimActor(Guid UserId, string Email, IReadOnlyList Roles); + + // The personas the fixture acts as. ReadOnly is deliberately absent: those + // users are created (the load test signs in as them) but no phase ever picks + // one, which is what makes "a ReadOnly persona authors nothing" a property of + // this type rather than of a rule someone has to remember. + // + // The restricted pair is Guid.Empty until RestrictOneWorkerAsync reports it, + // which is why every phase that needs it runs after that call. + private sealed record SimCast( + SimActor Owner, + IReadOnlyList Managers, + IReadOnlyList Sales, + IReadOnlyList Workers, + Guid RestrictedWorkerId, + Guid RestrictedFlockId); + + // Rotates deterministically through a pool. TOTAL for every pool, including + // an empty one: SimulationOptions validates no count, so Managers/Sales/ + // Workers may each be 0 and `pool[index % pool.Count]` would divide by zero. + // + // The fallback is a fidelity DEGRADATION, so it is never silent (#500 review + // round 2, finding 6) — hiding it would be the same class of defect #500 + // reports. Failing instead would be worse: Workers == 0 is already a + // deliberately tolerated configuration (RestrictOneWorkerAsync warns and + // carries on). + private SimActor Pick(IReadOnlyList pool, int index, SimActor fallback, string poolName) => + Pick(pool, index, () => fallback, poolName); + + // Deferred-fallback overload, and the reason it exists: WorkerFor nests one + // Pick inside another, and C# evaluates the inner call eagerly as an + // ARGUMENT. Through the overload above, that ran the manager-pool Pick on + // every production day and — at Managers == 0 — logged "provenance is + // degraded" for each of them, even though a worker was available and + // nothing had been substituted. A warning that fires when the thing it + // warns about did not happen is worse than no warning. (#500 mid-point + // review.) + private SimActor Pick(IReadOnlyList pool, int index, Func fallback, string poolName) + { + if (pool.Count > 0) return pool[index % pool.Count]; + + var substitute = fallback(); + logger.LogWarning( + "Simulation cast pool {Pool} is empty — rows a {Pool} persona would have authored are " + + "attributed to {Fallback} instead. The seeded fixture's provenance is degraded.", + poolName, poolName, substitute.Email); + return substitute; + } + + // Declares who the following handler calls act as. A pure assignment: + // CurrentUserContext.Resolve stores all three values verbatim — no database + // read, no role re-fetch — so the roles passed here are exactly the ones + // FlockScopeGuard reads. + private void ActAs(SimActor actor) => currentUser.Resolve(actor.UserId, actor.Email, actor.Roles); + + private async Task SeedCastAsync( + Guid accountId, SimActor owner, SimulationOptions sim, CancellationToken ct) + { + var managers = new List(); + for (var i = 1; i <= sim.Managers; i++) + managers.Add(await EnsureUserAsync( + accountId, $"sim-manager-{i}@{sim.EmailDomain}", Roles.Manager, + $"Sim Manager {i}", sim.CastPassword, ct)); + + var sales = new List(); + for (var i = 1; i <= sim.Sales; i++) + sales.Add(await EnsureUserAsync( + accountId, $"sim-sales-{i}@{sim.EmailDomain}", Roles.Sales, + $"Sim Sales {i}", sim.CastPassword, ct)); + + // Created, never picked — see SimCast. The load test signs in as these + // to exercise read-only routes; the fixture itself never acts as one. + for (var i = 1; i <= sim.ReadOnly; i++) + await EnsureUserAsync( + accountId, $"sim-readonly-{i}@{sim.EmailDomain}", Roles.ReadOnly, + $"Sim ReadOnly {i}", sim.CastPassword, ct); + + // Workers deliberately carry no role row (Roles.cs) — the seeder's own + // storedRole conversion below maps WorkerRole to a null role. + var workers = new List(); + for (var i = 1; i <= sim.Workers; i++) + workers.Add(await EnsureUserAsync( + accountId, $"sim-worker-{i}@{sim.EmailDomain}", WorkerRole, + $"Sim Worker {i}", sim.CastPassword, ct)); + + return new SimCast(owner, managers, sales, workers, Guid.Empty, Guid.Empty); + } + + // Which worker records a given flock's eggs on a given day. + // + // This is an AUTHORIZATION rule, not a fidelity nicety. RecordDailyEntry and + // SubmitDailyEntry both run FlockScopeGuard, which reads the very context + // ActAs writes; the one worker RestrictOneWorkerAsync narrowed carries a + // UserRoleAssignment row, so picking it for any other flock returns + // FlockScope.NotAssigned and fails the entire seed. Workers with no + // assignment row stay account-wide (the guard grandfathers them). + // + // Eligibility is therefore per-flock, and the counts differ: at the default + // cast the restricted flock has all 3 workers eligible and every other flock + // has 2. + private SimActor WorkerFor(SimCast cast, Guid flockId, int dayIndex) + { + var eligible = flockId == cast.RestrictedFlockId + ? cast.Workers + // Materialized because Pick takes an IReadOnlyList. + : [.. cast.Workers.Where(w => w.UserId != cast.RestrictedWorkerId)]; + + // Total by construction: an empty worker pool falls back to a manager, + // and an empty manager pool falls back to the Owner, who always exists + // (MissingBaseDataAsync refuses to seed without one). Both fallbacks + // warn — and the manager one is DEFERRED, so it warns only when the + // worker pool was actually empty. See the Pick overloads. + return Pick( + eligible, dayIndex, + () => Pick(cast.Managers, dayIndex, cast.Owner, "Managers"), + "Workers"); + } + + // #500 — returns the persona, not just its id. + // + // Both branches read actual Roles through Access, rather + // than the create branch trusting the role name it just passed in. That is + // deliberate: a persona rebuilt differently on a re-run than on a first run + // is exactly the silent divergence this fixture's determinism contract + // (#279) exists to prevent, and Roles is an authorization input. + private async Task EnsureUserAsync( + Guid accountId, string email, string role, string name, string password, CancellationToken ct) + { + // #532 — ACCOUNT-SCOPED, and this is the one that mattered. The global + // lookup adopted ANOTHER farm's user as this account's persona when only + // a foreign row existed, and threw outright once both farms had one + // (UserStore backs FindByEmailAsync with SingleOrDefaultAsync). + // SimActor.Roles is an authorization input (#500), so a mis-attributed + // persona is a tenant-isolation defect, not a cosmetic one. + var existing = await access.FindUserByEmailAsync(accountId, email, ct); + if (existing is null) + { + // #360 — the simulation command is a trusted, non-HTTP one-shot + // caller. Interactive creation is fail-closed in CreateUserHandler; + // this seeder uses Access fixture creation explicitly while + // acting as the real Owner resolved by SeedAsync. There is no flag, + // magic actor id, environment branch, or request-selectable bypass. + var storedRole = role == WorkerRole ? null : role; + var result = await operations.CreateUserAsync( + accountId, email.Trim(), password, storedRole, + name: UserName.Normalize(name), ct: ct); + Require(result, $"create cast user {email}"); + + existing = await access.FindUserByEmailAsync(accountId, email, ct) + ?? throw new InvalidOperationException( + $"Simulation seed: cast user {email} was created but cannot be read back."); + } + + var roles = existing.Roles; + + // #500 (codex round 4) — a DISABLED cast member is refused, for exactly + // the reason FindOwnerAsync refuses a disabled Owner one screen up. + // + // This only bites on a PARTIAL RE-RUN: the persona was created by an + // earlier run, somebody disabled it through the Users screen, and this + // run finds it by email. Disabling stamps DisabledAt and keeps every + // role row, so FindByEmailAsync returns it and the role check below + // passes — the seeder would then attribute newly written fixture history + // to a persona LoginAsync rejects (IdentityProvider line 41). The seed + // reports success and the fixture is unusable in the one way nobody + // checks, because the rows look perfectly well-formed. + if (existing.DisabledAt is not null) + throw new InvalidOperationException( + $"Simulation seed: cast user {email} exists but is DISABLED, so the fixture would name an " + + "actor who cannot log in. Re-enable that user from the Users screen (which clears both " + + "DisabledAt and DisabledBy), or reset the fixture."); + + // The persona must still HOLD the role this call site is staffing, or + // the fixture is not what it says it is (#500 final review, raised + // independently by two reviewers). + // + // Fetching the roles rather than fabricating them is right, but it means + // an existing user whose role was changed out from under the seeder is + // silently accepted into the wrong pool. A `sim-manager-1` demoted to + // ReadOnly would go on authoring the manager phases — and, holding no + // UserRoleAssignment rows, would sail through FlockScopeGuard on its + // "zero rows = account-wide" branch rather than being refused. The seed + // would then fail its exact-count validation much later, AFTER durable + // writes, with a message pointing at counts instead of at the cause. + // + // Fail here instead, naming both roles. + // + // #500 (codex round 4) — the Worker case is checked, not EXEMPT. "Worker" + // is a pseudo-role the seeder's storedRole conversion maps to null, so a worker holds no + // assignable role — which means the correct assertion for a worker is + // `roles is empty`, not "skip the check". Exempting it let a sim-worker + // promoted to Manager through the Users UI pass as a worker, and that is + // the worst version of this bug rather than a cosmetic one: the whole + // restricted-worker shape this fixture exists to exercise is a + // FlockScopeGuard narrowing, and Manager BYPASSES that guard by role. The + // rerun would go green having tested the opposite of what it claims. + if (role == WorkerRole) + { + if (roles.Count > 0) + throw new InvalidOperationException( + $"Simulation seed: cast user {email} is the fixture's WORKER persona, which must hold no " + + $"assignable role, but it holds: {string.Join(", ", roles)}. A worker promoted outside the " + + "seeder no longer exercises the FlockScopeGuard narrowing this fixture depends on " + + "(Manager and Owner bypass it by role). Restore that user to Worker, or reset the fixture."); + } + else if (!roles.Contains(role)) + throw new InvalidOperationException( + $"Simulation seed: cast user {email} exists but does not hold the {role} role " + + $"(it holds: {(roles.Count == 0 ? "no roles" : string.Join(", ", roles))}). The cast was " + + "reconfigured outside the seeder; fix that user's role, or reset the fixture."); + + return new SimActor(existing.Id, existing.Email!, roles); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Flocks.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Flocks.cs new file mode 100644 index 000000000..bcbef2507 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Flocks.cs @@ -0,0 +1,272 @@ +using Cluckwork.Application.Modules.FlockManagement.Contracts; +using Cluckwork.Domain.Modules.FlockManagement.Contracts; +using Cluckwork.Domain.Common; +using Microsoft.Extensions.Logging; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Minimal flock topology ---------------------------------------- + + // #279 review Fix 1 (codex): both flocks must be placed strictly OLDER + // than the deepest production-history entry SeedProductionHistoryAsync + // writes for EVERY flock (today.AddDays(-EffectiveHistoryDays(sim))) — + // previously House B was placed only ~70 days ago while + // Simulation:HistoryDays defaults to 90, so House B ended up with ~20 + // days of daily entries dated BEFORE it was placed. The margin below adds + // headroom on top of the history floor so placement is never merely + // equal to the oldest entry date. + private const int FlockPlacementMarginDays = 7; + + // #943 — shared with the production history, whose eggs are a lay rate + // over these birds less the removals the seeder itself writes. + private static readonly int[] OperationalFlockInitialCounts = [400, 350]; + + private async Task> SeedFlockTopologyAsync( + Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) + { + // Both flocks share the identical "safely older than all history" + // placement date — SeedFlockHistoryAsync's entry-date range + // (today.AddDays(-d) for d = 1..EffectiveHistoryDays(sim)) is the + // SAME for every flock, so both placements need the identical floor. + var placementDate = today.AddDays(-FlockPlacementLookbackDays(sim)); + + (string Name, string Breed, DateOnly PlacementDate, int InitialCount)[] wanted = + [ + ("Sim House A", "ISA Brown", placementDate, OperationalFlockInitialCounts[0]), + ("Sim House B", "Lohmann Brown", placementDate, OperationalFlockInitialCounts[1]), + ]; + + // A manager places the flocks (#500) — Flock.Create is audited, so this + // is what the SPA's History line names. Rotates per flock so a wider + // Managers pool spreads across the topology. + var ids = new List(); + for (var i = 0; i < wanted.Length; i++) + { + ActAs(Pick(cast.Managers, i, cast.Owner, "Managers")); + var f = wanted[i]; + ids.Add(await EnsureFlockAsync(accountId, f.Name, f.Breed, f.PlacementDate, f.InitialCount, ct)); + } + return ids; + } + + private async Task EnsureFlockAsync( + Guid accountId, string name, string breed, DateOnly placementDate, int initialCount, + CancellationToken ct) + { + // Tenant is resolved, so the query filter already scopes this to + // accountId. Duplicate names are legal, so a second flock with this + // name is drift the seed refuses to guess past (#852). + switch (await flocks.ResolveByNameAsync(name, ct)) + { + case FlockNameResolution.Found found: + return found.Flock.Id; + case FlockNameResolution.Ambiguous ambiguous: + throw new InvalidOperationException( + $"Simulation seed: {(ambiguous.MoreExist ? "more than " : "")}{ambiguous.Candidates.Count} flocks are named {name} " + + $"({string.Join(", ", ambiguous.Candidates.Select(c => c.Id))}). " + + "Rename the duplicates or reset the fixture."); + } + + var result = await flockModule.CreateAsync( + new CreateFlockCommand(name, breed, placementDate, initialCount), accountId, ct); + Require(result, $"create flock {name}"); + return result.Value; + } + + // #627 — the explicit bird-movement offsets run through 63, so the flock + // PLACEMENT (not the daily/feed/water/expense loop bounds — those keep + // EffectiveHistoryDays) needs at least 64 days of lookback: every explicit + // row must sit on/after its flock's placement date even in the shallowest + // fixture. For the 90-day default this is 97, exactly the pre-#627 value. + private static int FlockPlacementLookbackDays(SimulationOptions sim) => + Math.Max(EffectiveHistoryDays(sim) + FlockPlacementMarginDays, 64); + + // #627 — the independent picker catalog. Exactly 97 numbered Active rows + // ("Sim Z Flock Catalog 001".."097"), the Active page-two sentinel + // ("Sim Z Flock Page Two"), one row transitioned through + // DepleteFlockHandler ("Sim Z Flock Depleted"), and one through + // ArchiveFlockHandler ("Sim Z Flock Archived") — 100 Active / 1 Depleted + // / 1 Archived on top of the two operational houses, 102 flocks total + // (INV-5). None of these ids are ever returned to the caller, so no + // history fanout can reach them. + // #627 driver correction — every catalog row sorts AFTER the two + // operational houses: "Sim Z …" > "Sim House …". That is NOT the same as + // keeping all 100 catalog rows off page one of a 100-row default-limit + // picker — with the 101 non-archived seed rows, page one holds both houses + // plus the 98 earliest-sorting catalog rows, and only the lexically-last + // sentinel ("Sim Z Flock Page Two") falls onto page two. The guarantee is + // that the houses are INSIDE page one, not that the catalog is outside it. + private const string FlockCatalogNamePrefix = "Sim Z Flock Catalog "; + private const string FlockPageTwoSentinelName = "Sim Z Flock Page Two"; + private const string FlockDepletedName = "Sim Z Flock Depleted"; + private const string FlockArchivedName = "Sim Z Flock Archived"; + + private async Task SeedFlockCatalogAsync( + Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) + { + var placementDate = today.AddDays(-FlockPlacementLookbackDays(sim)); + var storeKeeper = Pick(cast.Managers, 1, cast.Owner, "Managers"); + // Local actor state, not the previous phase's: Flock.Create is audited + // (#500), so every one of the 100 catalog creations must attribute to + // the store-keeper by THIS phase's own resolution, reorder-safe. + ActAs(storeKeeper); + + for (var i = 1; i <= NumberedCatalogFlockCount; i++) // 97 numbered Active rows + await EnsureFlockAsync( + accountId, + $"{FlockCatalogNamePrefix}{i:000}", + "ISA Brown", + placementDate, + 100, + ct); + + await EnsureFlockAsync( + accountId, FlockPageTwoSentinelName, "ISA Brown", placementDate, 100, ct); + + // Lifecycle rows: create, then transition through the real handlers — + // the same domain rules the SPA enforces (deplete only from Active, + // archive from Active or Depleted). + var depletedId = await EnsureFlockAsync( + accountId, FlockDepletedName, "Lohmann Brown", placementDate, 100, ct); + await TransitionFlockAsync( + depletedId, FlockDepletedName, FlockStatus.Depleted, storeKeeper, + flockId => flockModule.DepleteAsync(flockId, ct), ct); + + var archivedId = await EnsureFlockAsync( + accountId, FlockArchivedName, "Lohmann Brown", placementDate, 100, ct); + await TransitionFlockAsync( + archivedId, FlockArchivedName, FlockStatus.Archived, storeKeeper, + flockId => flockModule.ArchiveAsync(flockId, ct), ct); + } + + // Rerun-safe lifecycle transition: a row already in the wanted status + // converges; a row in the WRONG status is drift the seed refuses to paper + // over (failing closed here rather than applying a second transition or + // silently accepting what the manifest's exact split would later reject). + private async Task TransitionFlockAsync( + Guid flockId, string name, FlockStatus wantedStatus, SimActor actor, + Func> transition, CancellationToken ct) + { + var flock = await flocks.GetAsync(flockId, ct) + ?? throw new InvalidOperationException( + $"Simulation seed: flock {name} was ensured but cannot be read back."); + if (flock.Status == wantedStatus) return; + + ActAs(actor); + var result = await transition(flockId); + if (result.IsFailure) + throw new InvalidOperationException( + $"Simulation seed: flock {name} exists in status {flock.Status} but the " + + $"transition to {wantedStatus} failed ({result.Error.Code}: {result.Error.Description}). " + + "A lifecycle row drifted outside the seeder — fix its status or reset the fixture."); + Require(result, $"transition flock {name} to {wantedStatus}"); + } + + // #627 — the explicit bird-adjustment band: 51 date-distinct rows on the + // first operational flock (Sim House A), through RecordBirdMovementHandler + // so every row carries the audit actor and the ledger's real semantics. + // + // The natural key is (FlockId, Date) — never the free-text note. The 51 + // offsets are the FIRST 51 positive day offsets not divisible by five, so + // no explicit row can land on a day SeedFlockHistoryAsync generates an + // automatic mortality row for (d % 5 == 0), and the last offset (63) is + // inside FlockPlacementLookbackDays' 64-day floor. The final row carries a + // stable sentinel note so the SPA ledger spec can prove the second page + // renders — but the note is DATA, not identity. + private const int ExplicitBirdMovementCount = 51; + // The 51st non-multiple of five (51 rows + 12 skipped offsets); the bound + // `LiveBirdsOnDay` walks the same band by. + private const int ExplicitBirdMovementLastOffset = 63; + private const string BirdMovementPageTwoSentinelNote = "Sim bird movement page two sentinel"; + + private async Task SeedExplicitBirdMovementsAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, + CancellationToken ct) + { + if (flockIds.Count == 0) + throw new InvalidOperationException( + "Simulation seed: no operational flocks exist to carry the explicit bird movements."); + + var flockId = flockIds[0]; + var storeKeeper = Pick(cast.Managers, 2, cast.Owner, "Managers"); + ActAs(storeKeeper); + + var offset = 0; + for (var d = 1; d <= FlockPlacementLookbackDays(sim) && offset < ExplicitBirdMovementCount; d++) + { + if (d % 5 == 0) continue; // automatic-mortality days stay unowned by this band + var date = today.AddDays(-d); + var isLast = offset == ExplicitBirdMovementCount - 1; + var note = isLast ? BirdMovementPageTwoSentinelNote : $"Simulation fixture explicit adjustment {d}"; + + var exists = await flockFixture.BirdMovementExistsAsync(flockId, date, ct); + if (!exists) + { + var result = await flockModule.RecordMovementAsync(new RecordBirdMovementCommand( + flockId, date, "Adjustment", 1, note), accountId, ct); + Require(result, $"record explicit bird movement for flock {flockId} on {date:yyyy-MM-dd}"); + } + offset++; + } + + // Fail closed rather than silently publish a short band: the probe + // above only skips rows that already exist, so a count short of 51 + // means the dates stopped being distinct. + var count = await flockFixture.CountAdjustmentsAsync(flockId, ct); + if (count < ExplicitBirdMovementCount) + throw new InvalidOperationException( + $"Simulation seed: only {count} explicit bird adjustments exist on the operational " + + $"flock; the fixture expects exactly {ExplicitBirdMovementCount} date-distinct rows."); + } + + // --- Restrict exactly one worker to one (not all) flocks ----------- + + // Returns the pair it restricted, so the caller can rebuild the cast record. + // + // BOTH the early returns matter, and the idempotent one is the trap (#500): + // it must report the EXISTING pair, never (Empty, Empty). Reporting Empty + // leaves RestrictedFlockId empty on every run after the first, so + // WorkerFor's `flockId == cast.RestrictedFlockId` never matches, the + // restricted worker becomes eligible for every flock, and the seed FAILS via + // FlockScopeGuard. A fully converged re-run hides it — the natural-key check + // short-circuits before WorkerFor is reached — so it surfaces only on a + // partial re-run that adds days. + private async Task<(Guid WorkerId, Guid FlockId)> RestrictOneWorkerAsync( + Guid accountId, SimCast cast, IReadOnlyList flockIds, CancellationToken ct) + { + // Granting a flock assignment is account administration — the Owner does + // it (#500). User.FlockAssign is audited. This phase runs directly after + // another Owner-authored one (SeedPrimaryTimeZoneAsync), so this ActAs is + // currently a no-op under today's ordering — kept for the same reason as + // that phase's own comment: order-independence, not decoration. + ActAs(cast.Owner); + + if (cast.Workers.Count == 0) + { + logger.LogWarning( + "Simulation:Workers is 0 — no worker exists to flock-restrict for the load test."); + return (Guid.Empty, Guid.Empty); + } + + if (flockIds.Count < 2) + throw new InvalidOperationException( + "Simulation seed needs at least 2 flocks so the restricted worker is genuinely " + + "narrowed (one assigned, one left out)."); + + var worker = cast.Workers[0]; + var flockId = flockIds[0]; + + // #606 — the interactive AssignFlockHandler now requires an + // interactive step-up grant this trusted, non-HTTP caller cannot + // hold, so the Access fixture writes the assignment with the SAME + // audit shape (actor, target email, flock NAME) the handler would + // have written — pinned by + // SimulationSeed_RestrictedWorkerAssignment_PreservesActorAndAuditDetails. + // An existing assignment is the idempotent re-run: the pair, not + // Empty. See the header. + await accessFixture.EnsureFlockAssignmentAsync(accountId, worker.UserId, worker.Email, flockId, ct); + return (worker.UserId, flockId); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Inventory.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Inventory.cs new file mode 100644 index 000000000..a0926782b --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Inventory.cs @@ -0,0 +1,378 @@ +using Cluckwork.Application.Modules.Finance.Contracts; +using Cluckwork.Application.Modules.GeneralInventory.Contracts; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Inventory: items, opening purchases, adjustments, feed/water usage, + // expenses (#243 Task 3c) -------------------------------------------- + // + // Same real-handler-reuse discipline as sales in the Sales partial (CreateInventoryItem/ + // RecordPurchase/RecordAdjustment/RecordFeedUsage/RecordWaterUsage/ + // CreateExpenseCategory/CreateExpense) so reports/exports built against + // this data see the same shapes a user clicking through Inventory/ + // Expenses would produce. Idempotency: items/categories by Name + // (EnsureFlockAsync-style); the opening purchase by "does the item + // already have a lot" (HasLotsAsync — this seeder only ever creates ONE + // opening lot per item); the adjustment by "does the lot already carry a + // correction movement"; usage/expense rows by the (flock/item, date) or + // Description natural key this seeder controls, same convention as + // SeedFlockHistoryAsync's natural-key skip and FindOrderAsync in the ProductionHistory and Sales partials. + + private static readonly (string Name, string Category, string Unit, long DefaultUnitCostMinorUnits)[] + InventoryItemsWanted = + [ + ("Sim Layer Feed", "Feed", "kg", 42), + ("Sim Pine Shavings", "Bedding", "bags", 850), + ]; + + private const decimal FeedOpeningPurchaseQuantity = 3000m; // kg + private const decimal BeddingOpeningPurchaseQuantity = 120m; // bags + private const decimal FeedDiscardQuantity = -15m; // kg — spoilage write-off + private const decimal BeddingAdjustmentQuantity = -6m; // bags — recount shrinkage + + // Modest relative to the opening purchase (net of the discard above) — + // total feed usage across every flock/day stays far inside what's on + // hand, so the seed never fails closed on insufficient stock regardless + // of how many flocks are configured. + private const decimal FeedUsagePerFlockPerDay = 18m; // kg + private const int FeedUsageDays = 4; + + private const decimal WaterUsagePerFlockPerDay = 250m; // L + private const int WaterUsageDays = 4; + + private async Task SeedInventoryOperationsAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, + CancellationToken ct) + { + // #500 — stores and feeding are a manager's job. Of everything below only + // InventoryItem.Adjust and Expense.Create write an audit row, but each + // helper takes its actor explicitly anyway: the caller owning the choice + // is what keeps a later reorder from silently changing attribution. + // Manager also matters functionally here — feed/water usage go through + // FlockScopeGuard, which passes a Manager by role. + var storeKeeper = Pick(cast.Managers, 0, cast.Owner, "Managers"); + var (feedItemId, feedLotId, beddingItemId, beddingLotId) = + await SeedInventoryItemsAndOpeningPurchasesAsync(accountId, today, cast, sim, ct); + + await SeedFeedUsageAsync(accountId, today, flockIds, feedItemId, storeKeeper, ct); + + // The two original correction rows, kept on opening + 2 (see the + // opening-lookback note in SeedInventoryItemsAndOpeningPurchasesAsync). + // They run AFTER the feed usages for the same domain reason as the + // adjustment band below: a discard/correction is legal only once the + // lot has been drawn down below its received quantity. + var adjustmentDate = today.AddDays(-(InventoryOpeningLookbackDays(sim) - 2)); + await EnsureAdjustmentAsync( + accountId, feedItemId, feedLotId, adjustmentDate, "Discard", + FeedDiscardQuantity, "Simulation fixture: torn bag spoiled in storage", storeKeeper, ct); + await EnsureAdjustmentAsync( + accountId, beddingItemId, beddingLotId, adjustmentDate, "Adjustment", + BeddingAdjustmentQuantity, "Simulation fixture: recount shrinkage", storeKeeper, ct); + + // #627 — the over-cap band, after the usages too (same rule). + await SeedFeedAdjustmentsAsync(accountId, today, feedItemId, feedLotId, storeKeeper, sim, ct); + + await SeedWaterUsageAsync(accountId, today, flockIds, storeKeeper, ct); + await SeedExpensesAsync(accountId, today, flockIds, cast, sim, ct); + } + + // #627 — the over-cap inventory band: exactly 110 date-distinct +1 + // Adjustment rows on the feed lot's OPENING lot. The item ends with ten + // other movements (one purchase, one discard, eight usages), so the band + // reaches exactly 120 on the feed item while the bedding item keeps its + // two, making the account-wide total exactly 122. + // + // Seeded AFTER the feed usages (which draw the lot down below its received + // quantity), never before: InventoryLot.Adjust refuses a correction that + // would leave a lot at or above what was received. The movement COUNT is + // order-independent (the ledger is append-only), so the final band is the + // same either way — only the legal order of the writes differs. + // + // Natural key (InventoryLotId, Date), never the free-text reason. Dates + // enumerate forward from opening + 1, skipping the existing discard at + // opening + 2, and stop after 110 rows — all on/after the lot's receipt + // and at least six days before the anchor. The oldest added row carries a + // stable sentinel reason so the SPA ledger spec can prove its second + // page renders; the reason is DATA, not identity. + private const int FeedAdjustmentCount = 110; + private const string FeedAdjustmentPageTwoSentinelReason = "Sim feed adjustment page two sentinel"; + + // Mirrors the openingDate note in + // SeedInventoryItemsAndOpeningPurchasesAsync — the two floors (the +5 + // margin and the 117-day #627 floor) are one decision and live next to + // each other there. + private static int InventoryOpeningLookbackDays(SimulationOptions sim) => + Math.Max(EffectiveHistoryDays(sim) + 5, 117); + + private async Task SeedFeedAdjustmentsAsync( + Guid accountId, DateOnly today, Guid feedItemId, Guid feedLotId, SimActor actor, + SimulationOptions sim, CancellationToken ct) + { + ActAs(actor); + + var added = 0; + for (var d = 1; d <= InventoryOpeningLookbackDays(sim) - 1 && added < FeedAdjustmentCount; d++) + { + if (d == 2) continue; // the existing discard owns opening + 2 + var date = today.AddDays(-(InventoryOpeningLookbackDays(sim) - d)); + var isOldest = added == 0; // first date enumerated == oldest == the sentinel + var reason = isOldest + ? FeedAdjustmentPageTwoSentinelReason + : $"Simulation fixture feed adjustment {added + 1}"; + + var exists = await inventoryFixture.FeedLotMovementExistsAsync(feedLotId, date, ct); + if (!exists) + { + var result = await inventory.RecordAdjustmentAsync(new RecordAdjustmentCommand( + feedItemId, feedLotId, date, "Adjustment", 1m, reason), accountId, ct); + Require(result, $"record feed adjustment for lot {feedLotId} on {date:yyyy-MM-dd}"); + } + added++; + } + + // Fail closed rather than silently publish a short band (the probe + // above only skips rows that already exist, so shortness means the + // dates stopped being distinct). + var count = await inventoryFixture.CountFeedLotAdjustmentsAsync(feedLotId, ct); + if (count < FeedAdjustmentCount) + throw new InvalidOperationException( + $"Simulation seed: only {count} feed-lot adjustment rows exist; the fixture " + + $"expects exactly {FeedAdjustmentCount} date-distinct rows."); + } + + // #627 — extracted (behavior-identical) from SeedInventoryOperationsAsync + // so the phase owns the item + opening-lot ids its callers (the band, + // the correction rows, the usage loops) all need: the band's +1 rows and + // the original discard/correction rows are all LOT CHANGES, and + // InventoryLot.Adjust's domain rule (AdjustExceedsReceived) refuses any + // change that would leave the lot at or above its received quantity — + // so every one of them is legal only once the feed usages have drawn the + // lot down. SeedInventoryOperationsAsync therefore seeds the usages + // BEFORE those rows; the append-only ledger makes the movement COUNT + // order-independent, so the certified bands are the same either way. The + // opening purchase itself still precedes every usage, as before. + private async Task<(Guid FeedItemId, Guid FeedLotId, Guid BeddingItemId, Guid BeddingLotId)> + SeedInventoryItemsAndOpeningPurchasesAsync( + Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) + { + var storeKeeper = Pick(cast.Managers, 0, cast.Owner, "Managers"); + + // Received well before any usage date so FIFO always finds it on-hand + // as-of any usage day — same "older than everything it must cover" + // shape as the flock placement dates in SeedFlockTopologyAsync. + // + // #627 — the feed lot's 110 deterministic adjustments need 116 distinct + // days of room on/after receipt (opening + 1 through opening + 116, + // minus the existing discard at opening + 2), so the opening lookback is + // floored at 117: the latest added date then lands at anchor day - 6, + // clear of the four recent feed-usage dates and any farm-today skew that + // trails the UTC anchor. #627 intentionally moves the 90-day default + // opening from 95 to 117 — the band needs the floor, not the history + // margin. + // + // The lot's received quantity (3000) is NOT sized for the band: the + // band's legality is ORDER, not headroom — the lot starts its life AT + // its received quantity, where InventoryLot.Adjust refuses even one +1 + // (AdjustExceedsReceived), so the 144 kg of usages and the -15 discard + // must run BEFORE the 110 +1 rows, which then land at 2951 < 3000. + var openingDate = today.AddDays(-InventoryOpeningLookbackDays(sim)); + + var itemIds = new Dictionary(); + foreach (var (name, category, unit, cost) in InventoryItemsWanted) + itemIds[name] = await EnsureInventoryItemAsync(accountId, name, category, unit, cost, storeKeeper, ct); + var feedItemId = itemIds["Sim Layer Feed"]; + var beddingItemId = itemIds["Sim Pine Shavings"]; + + var feedLotId = await EnsureOpeningPurchaseAsync( + accountId, feedItemId, openingDate, FeedOpeningPurchaseQuantity, storeKeeper, ct); + var beddingLotId = await EnsureOpeningPurchaseAsync( + accountId, beddingItemId, openingDate, BeddingOpeningPurchaseQuantity, storeKeeper, ct); + + return (feedItemId, feedLotId, beddingItemId, beddingLotId); + } + + private async Task EnsureInventoryItemAsync( + Guid accountId, string name, string category, string unit, long defaultUnitCostMinorUnits, + SimActor actor, CancellationToken ct) + { + if (await inventoryFixture.FindItemIdByNameAsync(name, ct) is Guid existingId) return existingId; + + ActAs(actor); + var result = await inventory.CreateItemAsync( + new CreateInventoryItemCommand(name, category, unit, defaultUnitCostMinorUnits), accountId, ct); + Require(result, $"create inventory item {name}"); + return result.Value; + } + + private async Task EnsureOpeningPurchaseAsync( + Guid accountId, Guid itemId, DateOnly receivedDate, decimal quantity, SimActor actor, + CancellationToken ct) + { + // The lot check doubles as the idempotency probe: this seeder only + // ever creates ONE opening lot per item, so "any lot exists" is + // equivalent to "the opening purchase already ran". + if (await inventoryFixture.FindLotIdAsync(itemId, ct) is Guid existingLotId) return existingLotId; + + ActAs(actor); + // UnitCostMinorUnits omitted: falls back to the item's default cost + // set above. + var result = await inventory.RecordPurchaseAsync(new RecordPurchaseCommand( + itemId, receivedDate, quantity, UnitCostMinorUnits: null, LotNumber: "SIM-OPEN-1", + ExpiryDate: null, Note: "Simulation fixture opening stock"), accountId, ct); + Require(result, $"record opening purchase for inventory item {itemId}"); + return result.Value; + } + + private async Task EnsureAdjustmentAsync( + Guid accountId, Guid itemId, Guid lotId, DateOnly date, string type, decimal quantityDelta, string reason, + SimActor actor, CancellationToken ct) + { + if (await inventoryFixture.HasAdjustmentOrDiscardAsync(lotId, ct)) return; + + ActAs(actor); + var result = await inventory.RecordAdjustmentAsync(new RecordAdjustmentCommand( + itemId, lotId, date, type, quantityDelta, reason), accountId, ct); + Require(result, $"record inventory adjustment for lot {lotId}"); + } + + private async Task SeedFeedUsageAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, Guid feedItemId, SimActor actor, + CancellationToken ct) + { + // RecordFeedUsageHandler goes through FlockScopeGuard — the actor must be + // one the guard lets past for BOTH flocks, which a Manager is by role. + ActAs(actor); + + foreach (var flockId in flockIds) + for (var d = 1; d <= FeedUsageDays; d++) + { + // Day 1+ past, matching the daily-entry-history convention + // in the ProductionHistory partial (day-of-anchor could spuriously read as "future" + // against the farm's own, timezone-skewed today). + var date = today.AddDays(-d); + if (await inventoryFixture.FeedUsageExistsAsync(flockId, feedItemId, date, ct)) continue; + + var result = await inventory.RecordFeedUsageAsync(new RecordFeedUsageCommand( + flockId, feedItemId, date, FeedUsagePerFlockPerDay, + "Simulation fixture daily feeding"), accountId, ct); + Require(result, $"record feed usage for flock {flockId} on {date:yyyy-MM-dd}"); + } + } + + private async Task SeedWaterUsageAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, SimActor actor, CancellationToken ct) + { + // Flock-scoped, same as feed usage above. + ActAs(actor); + + foreach (var flockId in flockIds) + for (var d = 1; d <= WaterUsageDays; d++) + { + var date = today.AddDays(-d); + if (await inventoryFixture.WaterUsageExistsAsync(flockId, date, ct)) continue; + + var result = await inventory.RecordWaterUsageAsync(new RecordWaterUsageCommand( + flockId, date, WaterUsagePerFlockPerDay, Unit: "L", Source: "Well", + MeterStart: null, MeterEnd: null, Note: "Simulation fixture daily water"), accountId, ct); + Require(result, $"record water usage for flock {flockId} on {date:yyyy-MM-dd}"); + } + } + + private static readonly string[] ExpenseCategoriesWanted = ["Sim Utilities", "Sim Repairs & Maintenance"]; + + // Deterministic recurring expense amount — see RecurringStartDay/ + // RecurringCadenceDays in the Sales partial for why this exists (same thin-tail problem + // as sales, same fix). + private const long RecurringExpenseAmountMinorUnits = 4_000; + + private async Task SeedExpensesAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, + CancellationToken ct) + { + var categoryIds = await SeedExpenseCategoriesAsync(accountId, cast, ct); + + // flockIds[1] is safe here: RestrictOneWorkerAsync already throws if + // fewer than 2 flocks exist, and it runs before this step. + (string Category, int DaysAgo, string Description, long AmountMinorUnits, Guid? FlockId)[] wanted = + [ + ("Sim Utilities", 7, "Sim Electricity Bill", 15_000, null), + ("Sim Utilities", 3, "Sim Water Utility Bill", 8_000, null), + ("Sim Repairs & Maintenance", 5, "Sim Coop Roof Repair", 42_000, flockIds[0]), + ("Sim Repairs & Maintenance", 2, "Sim Feeder Replacement Part", 6_500, flockIds[1]), + ]; + + // Expense.Create is audited — a manager books the farm's spending (#500). + // Rotates per expense so a wider Managers pool spreads across them. + for (var i = 0; i < wanted.Length; i++) + { + var (category, daysAgo, description, amount, flockId) = wanted[i]; + await EnsureExpenseAsync( + accountId, categoryIds[category], today.AddDays(-daysAgo), description, amount, flockId, + Pick(cast.Managers, i, cast.Owner, "Managers"), ct); + } + + await SeedRecurringExpensesAsync(accountId, today, flockIds, categoryIds, cast, sim, ct); + } + + // Spreads a modest, deterministic drip of additional expenses across the + // full history window — mirrors SeedRecurringOrdersAsync. Category and + // flock attribution alternate so both categories and both flock-attributed + // / farm-wide expenses keep growing as HistoryDays grows, not just the + // count. + private async Task SeedRecurringExpensesAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, + IReadOnlyDictionary categoryIds, SimCast cast, SimulationOptions sim, CancellationToken ct) + { + var historyDays = EffectiveHistoryDays(sim); + var i = 0; + for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays, i++) + { + var category = i % 2 == 0 ? "Sim Utilities" : "Sim Repairs & Maintenance"; + var flockId = i % 2 == 0 ? (Guid?)null : flockIds[i % flockIds.Count]; + var description = $"Sim Recurring Expense Day {d}"; + await EnsureExpenseAsync( + accountId, categoryIds[category], today.AddDays(-d), description, + RecurringExpenseAmountMinorUnits, flockId, + Pick(cast.Managers, i, cast.Owner, "Managers"), ct); + } + } + + private async Task> SeedExpenseCategoriesAsync( + Guid accountId, SimCast cast, CancellationToken ct) + { + var categoryIds = new Dictionary(); + for (var i = 0; i < ExpenseCategoriesWanted.Length; i++) + categoryIds[ExpenseCategoriesWanted[i]] = await EnsureExpenseCategoryAsync( + accountId, ExpenseCategoriesWanted[i], Pick(cast.Managers, i, cast.Owner, "Managers"), ct); + return categoryIds; + } + + private async Task EnsureExpenseCategoryAsync( + Guid accountId, string name, SimActor actor, CancellationToken ct) + { + if (await financeFixture.FindCategoryIdByNameAsync(name, ct) is Guid existingId) return existingId; + + ActAs(actor); + var result = await finance.CreateCategoryAsync(new CreateExpenseCategoryCommand(name), accountId, ct); + Require(result, $"create expense category {name}"); + return result.Value; + } + + private async Task EnsureExpenseAsync( + Guid accountId, Guid categoryId, DateOnly date, string description, long amountMinorUnits, Guid? flockId, + SimActor actor, CancellationToken ct) + { + // Description is the natural key THIS seeder controls (every entry + // above is a distinct fixture string) — same convention as + // FindOrderAsync's (CustomerId, OrderDate) in the Sales partial. + if (await financeFixture.ExpenseExistsAsync(description, ct)) return; + + ActAs(actor); + var result = await finance.CreateExpenseAsync(new CreateExpenseCommand( + categoryId, date, description, amountMinorUnits, flockId, + Note: "Simulation fixture expense"), accountId, ct); + Require(result, $"create expense {description}"); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Manifest.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Manifest.cs new file mode 100644 index 000000000..67edc88ed --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Manifest.cs @@ -0,0 +1,451 @@ +using Cluckwork.Domain.Modules.Farm.Contracts; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + private async Task EmitManifestAsync( + Guid accountId, DateOnly today, SimulationOptions sim, CancellationToken ct) + { + var (counts, states) = await ComputeCountsAsync(accountId, ct); + // Same farm-local "today" lookup DailyEntryLockSweep itself just used + // (DrainLockSweepAsync already ran it to exhaustion, in + // SeedAsync — #638) — recomputing + // it here mirrors the sweep's OWN cutoff derivation exactly instead + // of approximating it, at the cost of the same vanishingly small + // UTC-midnight-mid-SeedAsync race the class header already documents + // for MinSentinelAgeDays. + var farmToday = clock.TodayInZone(sim.TimeZoneId); + var expected = ComputeExpectedCounts(sim, today, farmToday); + + // Fail-closed: throws on ANY shortfall — a partial/short seed must + // fail startup, not publish a "complete" manifest. + ValidateCounts(counts, states, expected); + + var fingerprint = ComputeFingerprint(sim.Seed, counts, states); + var manifest = new SimulationManifest( + SchemaVersion: ManifestSchemaVersion, + Seed: sim.Seed, + HistoryDays: sim.HistoryDays, + GeneratedAtAnchor: today, + Counts: counts, + LifecycleStates: states, + Complete: true, + Fingerprint: fingerprint); + + if (!string.IsNullOrWhiteSpace(sim.CredentialOutputPath)) + await WriteManifestFileAsync(sim.CredentialOutputPath, manifest, ct); + + logger.LogInformation( + "Simulation seed manifest complete (fingerprint {Fingerprint}).", fingerprint); + + return manifest; + } + + // Everything below except Users/Accounts already sits behind AppDbContext's + // tenant query filter (OnModelCreating), and SeedAsync resolved the + // tenant to accountId at the top — so these queries are already scoped to + // the primary account without an explicit .Where(AccountId == accountId). + // ApplicationUser carries no tenant filter (it's Identity's own table, + // not one of ours), so users are filtered explicitly. Account carries a + // filter too, but IgnoreQueryFilters is used deliberately — the manifest + // needs to see the second, pristine tenant fixture as well (mirrors + // SeedSecondAccountAsync's own IgnoreQueryFilters use). + private async Task<(SimulationManifestCounts Counts, SimulationLifecycleStates States)> ComputeCountsAsync( + Guid accountId, CancellationToken ct) + { + var usersTotal = await access.CountUsersAsync(accountId, ct); + var ownerCount = (await access.ListUsersInRoleAsync(accountId, Roles.Owner, ct)).Count; + var managerCount = (await access.ListUsersInRoleAsync(accountId, Roles.Manager, ct)).Count; + var salesCount = (await access.ListUsersInRoleAsync(accountId, Roles.Sales, ct)).Count; + var readOnlyCount = (await access.ListUsersInRoleAsync(accountId, Roles.ReadOnly, ct)).Count; + // Workers deliberately carry no role row (SeedCastAsync) — derive by + // subtraction rather than a role lookup that would always find none. + var workerCount = usersTotal - ownerCount - managerCount - salesCount - readOnlyCount; + + var accountCount = await farmFixture.CountAccountsAsync(ct); + // #627 — the flock lifecycle split the manifest now certifies exactly + // (INV-5). Every count but the accounts one is tenant-filtered. + var flockCounts = await flockFixture.CountAsync(ct); + var eggCounts = await eggsFixture.CountAsync(ct); + var commerceCounts = await commerceFixture.CountAsync(ct); + var inventoryCounts = await inventoryFixture.CountAsync(ct); + var financeCounts = await financeFixture.CountAsync(ct); + + var counts = new SimulationManifestCounts( + Accounts: accountCount, + Customers: commerceCounts.Customers, + BirdMovements: flockCounts.BirdMovements, + Owners: ownerCount, + Managers: managerCount, + Sales: salesCount, + Workers: workerCount, + ReadOnly: readOnlyCount, + UsersTotal: usersTotal, + Flocks: flockCounts.Flocks, + DailyEntriesTotal: eggCounts.DailyEntries, + EggLots: eggCounts.EggLots, + SalesOrdersTotal: commerceCounts.SalesOrders, + Payments: commerceCounts.Payments, + InventoryItems: inventoryCounts.Items, + InventoryLots: inventoryCounts.Lots, + InventoryMovementsTotal: inventoryCounts.Movements, + FeedUsageRows: inventoryCounts.FeedUsages, + WaterUsageRows: inventoryCounts.WaterUsages, + ExpenseCategories: financeCounts.ExpenseCategories, + Expenses: financeCounts.Expenses); + + var states = new SimulationLifecycleStates( + DailyEntries: new SimulationDailyEntryStates( + eggCounts.DraftEntries, eggCounts.SubmittedEntries, eggCounts.LockedEntries), + Flocks: new SimulationFlockStates( + flockCounts.ActiveFlocks, flockCounts.DepletedFlocks, flockCounts.ArchivedFlocks), + SalesOrders: new SimulationSalesOrderStates( + commerceCounts.DraftOrders, commerceCounts.ConfirmedOrders, commerceCounts.ShippedOrders, + commerceCounts.InvoicedOrders, commerceCounts.CancelledOrders, commerceCounts.VoidedOrders), + InventoryMovements: new SimulationInventoryMovementStates( + inventoryCounts.PurchaseMovements, inventoryCounts.UsageMovements, + inventoryCounts.AdjustmentMovements, inventoryCounts.DiscardMovements)); + + return (counts, states); + } + + // Pure function of SimulationOptions plus the two plain DateOnly anchors + // (today, farmToday) — still no DB access, so a unit test can call it + // with hand-built inputs too. See the class-level comment at the top of + // the ManifestExpectations partial for why every field here is DERIVED rather than a + // hardcoded depth-specific number. + private static SimulationExpectedCounts ComputeExpectedCounts(SimulationOptions sim, DateOnly today, DateOnly farmToday) + { + var historyDays = EffectiveHistoryDays(sim); + var recurringPoints = RecurringPointCount(historyDays); + + var dailyEntriesTotal = FlockTopologyCount * historyDays; + var draftEntries = FlockTopologyCount * DraftWindowDays; + var lockedEntries = ExpectedLockedEntryCount(today, farmToday, historyDays); + var submittedEntries = dailyEntriesTotal - draftEntries - lockedEntries; + // Entries that ever reached Submitted (whether still Submitted or + // since moved to Locked) — the population SubmitDailyEntryHandler + // actually minted egg lots for; Draft entries never call it. + var submittedOrLockedEntries = dailyEntriesTotal - draftEntries; + + var salesOrdersConfirmed = LifecycleConfirmedOrdersCount + recurringPoints; + var feedUsageRows = FlockTopologyCount * FeedUsageDays; + // One Purchase + one Adjustment/Discard movement per inventory item, + // plus one Usage movement per feed usage row — EnsureOpeningPurchaseAsync + // creates exactly one lot per item, so FIFO in RecordFeedUsageHandler + // never needs to split a single usage across more than one lot. + // + // #627 — plus the feed lot's explicit adjustment band (the bedding + // item is untouched by it): the feed item lands on exactly 10 + + // FeedAdjustmentBandCount = 120 movements, the bedding item keeps its + // 2, and the account-wide total below is 122 at the fixture scale. + var inventoryMovementsTotal = + InventoryItemsWanted.Length + InventoryItemsWanted.Length + + FeedAdjustmentBandCount + feedUsageRows; + + return new SimulationExpectedCounts( + Accounts: 2, // primary + SecondAccountId — never a third on a re-run. + Owners: 1, // the reused seeded admin — this seeder never creates a second Owner. + Managers: sim.Managers, + Sales: sim.Sales, + Workers: sim.Workers, + ReadOnly: sim.ReadOnly, + UsersTotal: 1 + sim.Managers + sim.Sales + sim.Workers + sim.ReadOnly, + // #627 — two operational houses + the exact picker catalog. + Flocks: FlockTopologyCount + CatalogFlockBandCount, + FlocksActive: FlockTopologyCount + NumberedCatalogFlockCount + 1, + FlocksDepleted: 1, + FlocksArchived: 1, + Customers: CustomersWanted.Length + CustomerBandCount, + // #627 — automatic mortality baseline (explicit day loop, per + // flock) plus the explicit adjustment band on the first + // operational flock. No other flock carries explicit rows, and no + // cull row is ever seeded, so this is the account-wide total. + BirdMovements: FlockTopologyCount * AutomaticMortalityPerFlock(historyDays) + + ExplicitBirdMovementsCount, + DailyEntriesTotal: dailyEntriesTotal, + DraftEntries: draftEntries, + SubmittedEntries: submittedEntries, + LockedEntries: lockedEntries, + EggLots: (GradesPerDailyEntry + ConditionLotsPerDailyEntry) * submittedOrLockedEntries, + SalesOrdersTotal: DraftOrdersCount + salesOrdersConfirmed, + SalesOrdersDraft: DraftOrdersCount, + SalesOrdersConfirmed: salesOrdersConfirmed, + Payments: 1, // exactly one partial payment (EnsurePartialPaymentAsync). + InventoryItems: InventoryItemsWanted.Length, + InventoryLots: InventoryItemsWanted.Length, // one opening lot per item. + InventoryPurchaseMovements: InventoryItemsWanted.Length, + InventoryAdjustmentOrDiscardMovements: InventoryItemsWanted.Length + + FeedAdjustmentBandCount, // one discard + the feed adjustment band + InventoryUsageMovements: feedUsageRows, + InventoryMovementsTotal: inventoryMovementsTotal, + FeedUsageRows: feedUsageRows, + WaterUsageRows: FlockTopologyCount * WaterUsageDays, + ExpenseCategories: ExpenseCategoriesWanted.Length, + Expenses: LifecycleExpensesCount + recurringPoints); + } + + // Pure, no DB access — a test can hand this a synthetic short + // SimulationManifestCounts/SimulationLifecycleStates and assert it throws, + // proving the fail-closed path without needing to sabotage a real seed run. + // The fail-closed gate, run AFTER the lock sweep: asserts every count EXACTLY + // (including the Draft/Submitted/Locked split) and throws on any shortfall, + // collecting every mismatch so one failed run reports the WHOLE shortfall. + // (#279: the "already seeded?" question is answered by the durable + // SimulationSeedState.CompletedAtUtc marker, NOT by a tolerant count probe, + // so this method has a single exact mode again.) + internal static void ValidateCounts( + SimulationManifestCounts counts, SimulationLifecycleStates states, SimulationExpectedCounts expected) + { + var failures = new List(); + void Check(bool ok, string message) + { + if (!ok) failures.Add(message); + } + + Check(counts.Accounts == expected.Accounts, + $"accounts: expected exactly {expected.Accounts}, got {counts.Accounts}"); + Check(counts.Owners == expected.Owners, $"users.owners: expected {expected.Owners}, got {counts.Owners}"); + Check(counts.Managers == expected.Managers, + $"users.managers: expected {expected.Managers}, got {counts.Managers}"); + Check(counts.Sales == expected.Sales, $"users.sales: expected {expected.Sales}, got {counts.Sales}"); + Check(counts.Workers == expected.Workers, $"users.workers: expected {expected.Workers}, got {counts.Workers}"); + Check(counts.ReadOnly == expected.ReadOnly, + $"users.readOnly: expected {expected.ReadOnly}, got {counts.ReadOnly}"); + Check(counts.UsersTotal == expected.UsersTotal, + $"users.total: expected {expected.UsersTotal}, got {counts.UsersTotal}"); + Check(counts.Flocks == expected.Flocks, $"flocks: expected {expected.Flocks}, got {counts.Flocks}"); + // #627 — the flock lifecycle split, exact (INV-5) plus a reconciliation + // sum so the three bands cannot hide a fourth status the counts don't + // name. + Check(states.Flocks.Active == expected.FlocksActive, + $"flocks.active: expected {expected.FlocksActive}, got {states.Flocks.Active}"); + Check(states.Flocks.Depleted == expected.FlocksDepleted, + $"flocks.depleted: expected {expected.FlocksDepleted}, got {states.Flocks.Depleted}"); + Check(states.Flocks.Archived == expected.FlocksArchived, + $"flocks.archived: expected {expected.FlocksArchived}, got {states.Flocks.Archived}"); + var flockStateSum = states.Flocks.Active + states.Flocks.Depleted + states.Flocks.Archived; + Check(flockStateSum == counts.Flocks, + $"flocks reconciliation: active+depleted+archived ({flockStateSum}) != total ({counts.Flocks})"); + Check(counts.Customers == expected.Customers, + $"customers: expected {expected.Customers}, got {counts.Customers}"); + Check(counts.BirdMovements == expected.BirdMovements, + $"birdMovements: expected {expected.BirdMovements}, got {counts.BirdMovements}"); + Check(counts.DailyEntriesTotal == expected.DailyEntriesTotal, + $"dailyEntries.total: expected {expected.DailyEntriesTotal}, got {counts.DailyEntriesTotal}"); + Check(states.DailyEntries.Draft == expected.DraftEntries, + $"dailyEntries.draft: expected {expected.DraftEntries}, got {states.DailyEntries.Draft}"); + Check(states.DailyEntries.Submitted == expected.SubmittedEntries, + $"dailyEntries.submitted: expected {expected.SubmittedEntries}, got {states.DailyEntries.Submitted}"); + Check(states.DailyEntries.Locked == expected.LockedEntries, + $"dailyEntries.locked: expected {expected.LockedEntries}, got {states.DailyEntries.Locked}"); + var dailyEntriesSum = states.DailyEntries.Draft + states.DailyEntries.Submitted + states.DailyEntries.Locked; + Check(dailyEntriesSum == counts.DailyEntriesTotal, + $"dailyEntries reconciliation: draft+submitted+locked ({dailyEntriesSum}) != total ({counts.DailyEntriesTotal})"); + Check(counts.EggLots == expected.EggLots, $"eggLots: expected {expected.EggLots}, got {counts.EggLots}"); + Check(counts.SalesOrdersTotal == expected.SalesOrdersTotal, + $"salesOrders.total: expected {expected.SalesOrdersTotal}, got {counts.SalesOrdersTotal}"); + Check(states.SalesOrders.Draft == expected.SalesOrdersDraft, + $"salesOrders.draft: expected {expected.SalesOrdersDraft}, got {states.SalesOrders.Draft}"); + Check(states.SalesOrders.Confirmed == expected.SalesOrdersConfirmed, + $"salesOrders.confirmed: expected {expected.SalesOrdersConfirmed}, got {states.SalesOrders.Confirmed}"); + // The seeder never ships/invoices/cancels/voids anything it creates + // (SeedSalesAsync's own header comment) — these terminal states must + // stay at exactly zero, not merely unchecked. + Check(states.SalesOrders.Shipped == 0, $"salesOrders.shipped: expected 0, got {states.SalesOrders.Shipped}"); + Check(states.SalesOrders.Invoiced == 0, $"salesOrders.invoiced: expected 0, got {states.SalesOrders.Invoiced}"); + Check(states.SalesOrders.Cancelled == 0, $"salesOrders.cancelled: expected 0, got {states.SalesOrders.Cancelled}"); + Check(states.SalesOrders.Voided == 0, $"salesOrders.voided: expected 0, got {states.SalesOrders.Voided}"); + var salesOrdersSum = states.SalesOrders.Draft + states.SalesOrders.Confirmed + states.SalesOrders.Shipped + + states.SalesOrders.Invoiced + states.SalesOrders.Cancelled + states.SalesOrders.Voided; + Check(salesOrdersSum == counts.SalesOrdersTotal, + $"salesOrders reconciliation: sum of states ({salesOrdersSum}) != total ({counts.SalesOrdersTotal})"); + Check(counts.Payments == expected.Payments, $"payments: expected {expected.Payments}, got {counts.Payments}"); + Check(counts.InventoryItems == expected.InventoryItems, + $"inventoryItems: expected {expected.InventoryItems}, got {counts.InventoryItems}"); + Check(counts.InventoryLots == expected.InventoryLots, + $"inventoryLots: expected {expected.InventoryLots}, got {counts.InventoryLots}"); + Check(states.InventoryMovements.Purchase == expected.InventoryPurchaseMovements, + $"inventoryMovements.purchase: expected {expected.InventoryPurchaseMovements}, got {states.InventoryMovements.Purchase}"); + Check(states.InventoryMovements.Adjustment + states.InventoryMovements.Discard + == expected.InventoryAdjustmentOrDiscardMovements, + $"inventoryMovements.adjustment+discard: expected {expected.InventoryAdjustmentOrDiscardMovements}, " + + $"got {states.InventoryMovements.Adjustment + states.InventoryMovements.Discard}"); + Check(states.InventoryMovements.Usage == expected.InventoryUsageMovements, + $"inventoryMovements.usage: expected {expected.InventoryUsageMovements}, got {states.InventoryMovements.Usage}"); + Check(counts.InventoryMovementsTotal == expected.InventoryMovementsTotal, + $"inventoryMovements.total: expected {expected.InventoryMovementsTotal}, got {counts.InventoryMovementsTotal}"); + var inventoryMovementsSum = states.InventoryMovements.Purchase + states.InventoryMovements.Usage + + states.InventoryMovements.Adjustment + states.InventoryMovements.Discard; + Check(inventoryMovementsSum == counts.InventoryMovementsTotal, + $"inventoryMovements reconciliation: sum of states ({inventoryMovementsSum}) != total ({counts.InventoryMovementsTotal})"); + Check(counts.FeedUsageRows == expected.FeedUsageRows, + $"feedUsageRows: expected {expected.FeedUsageRows}, got {counts.FeedUsageRows}"); + Check(counts.WaterUsageRows == expected.WaterUsageRows, + $"waterUsageRows: expected {expected.WaterUsageRows}, got {counts.WaterUsageRows}"); + Check(counts.ExpenseCategories == expected.ExpenseCategories, + $"expenseCategories: expected {expected.ExpenseCategories}, got {counts.ExpenseCategories}"); + Check(counts.Expenses == expected.Expenses, $"expenses: expected {expected.Expenses}, got {counts.Expenses}"); + + if (failures.Count > 0) + throw new InvalidOperationException( + "Simulation seed completion check failed — the seed is short/partial and must NOT be " + + "marked complete: " + string.Join("; ", failures)); + } + + private static readonly JsonSerializerOptions FingerprintJsonOptions = new(JsonSerializerDefaults.Web); + + // Stable hash of counts+seed (same Convert.ToHexString(SHA256.HashData(...)) + // convention as FarmLogo.ContentHash / IdempotencyMiddleware.Sha256) — a + // re-run that recomputes the SAME counts from the SAME seed always + // produces the SAME fingerprint (idempotent), and any real shortfall + // changes it. + // + // #279 review Fix 3 (Agent A): states.DailyEntries (the Draft/Submitted/ + // Locked split) is deliberately EXCLUDED — it depends on where the + // farm-local midnight lock-sweep boundary (DailyEntryLockSweep. + // LockAfterDays) falls relative to this seeder's UTC "today" anchor at + // the moment the sweep runs, so two otherwise-identical seed runs + // straddling that boundary would fingerprint differently even though + // every seeder-controlled COUNT (including DailyEntriesTotal) matches — + // a latent flake, and not reproducible day-to-day. states.SalesOrders and + // states.InventoryMovements stay in the hash: nothing time-based ever + // moves a sales order or inventory movement between states after the + // seeder writes it, so those splits are exactly as stable as counts. The + // split itself is still reported — just via manifest.LifecycleStates + // (#277), not folded into the fingerprint. Internal (not private) so + // SimulationSeederTests can probe it directly rather than only through + // the wall-clock-dependent integration path. + internal static string ComputeFingerprint(int seed, SimulationManifestCounts counts, SimulationLifecycleStates states) + { + // #627 — states.Flocks joins the canonical payload: nothing time-based + // moves a flock between Active/Depleted/Archived after the seeder + // writes it, so the split is exactly as stable as the other included + // states. states.DailyEntries stays deliberately EXCLUDED (the + // sweep-boundary flake the comment above documents). + var canonical = JsonSerializer.Serialize( + new { seed, counts, states.SalesOrders, states.InventoryMovements, states.Flocks }, FingerprintJsonOptions); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + + private static readonly JsonSerializerOptions ManifestFileJsonOptions = + new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + private async Task WriteManifestFileAsync(string path, SimulationManifest manifest, CancellationToken ct) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + // Atomic: serialize the whole manifest (complete + fingerprint + // included — they're computed last, in EmitManifestAsync, before this + // is ever called) to a temp file in the SAME directory, then rename + // over the final path. A reader (the #243 findings header, #277's + // Playwright suite) never observes a partially-written manifest — + // File.Move onto an existing path is a single filesystem rename, not + // a truncate-then-write. + var tempPath = path + ".tmp"; + await using (var stream = File.Create(tempPath)) + await JsonSerializer.SerializeAsync(stream, manifest, ManifestFileJsonOptions, ct); + File.Move(tempPath, path, overwrite: true); + + logger.LogInformation("Simulation seed manifest written to {Path}.", path); + } +} + +public sealed record SimulationDailyEntryStates(int Draft, int Submitted, int Locked); + +public sealed record SimulationSalesOrderStates( + int Draft, int Confirmed, int Shipped, int Invoiced, int Cancelled, int Voided); + +public sealed record SimulationInventoryMovementStates(int Purchase, int Usage, int Adjustment, int Discard); + +public sealed record SimulationFlockStates(int Active, int Depleted, int Archived); + +public sealed record SimulationLifecycleStates( + SimulationDailyEntryStates DailyEntries, + SimulationSalesOrderStates SalesOrders, + SimulationInventoryMovementStates InventoryMovements, + SimulationFlockStates Flocks); + +public sealed record SimulationManifestCounts( + int Accounts, + int Customers, + int BirdMovements, + int Owners, + int Managers, + int Sales, + int Workers, + int ReadOnly, + int UsersTotal, + int Flocks, + int DailyEntriesTotal, + int EggLots, + int SalesOrdersTotal, + int Payments, + int InventoryItems, + int InventoryLots, + int InventoryMovementsTotal, + int FeedUsageRows, + int WaterUsageRows, + int ExpenseCategories, + int Expenses); + +// Not persisted directly — folded into ValidateCounts' failure messages +// above. Kept as its own type (rather than loose parameters) so +// ComputeExpectedCounts and ValidateCounts share one shape, and so a test can +// hand-build one for the fail-closed assertion. +public sealed record SimulationExpectedCounts( + int Accounts, + int Owners, + int Managers, + int Sales, + int Workers, + int ReadOnly, + int UsersTotal, + int Flocks, + int FlocksActive, + int FlocksDepleted, + int FlocksArchived, + int Customers, + int BirdMovements, + int DailyEntriesTotal, + int DraftEntries, + int SubmittedEntries, + int LockedEntries, + int EggLots, + int SalesOrdersTotal, + int SalesOrdersDraft, + int SalesOrdersConfirmed, + int Payments, + int InventoryItems, + int InventoryLots, + int InventoryPurchaseMovements, + int InventoryAdjustmentOrDiscardMovements, + int InventoryUsageMovements, + int InventoryMovementsTotal, + int FeedUsageRows, + int WaterUsageRows, + int ExpenseCategories, + int Expenses); + +// #243 Task 3e — the seed's machine-readable completion artifact. schemaVersion +// lets a future breaking reshape be detected by consumers instead of silently +// misparsed; complete/fingerprint are always the last fields computed +// (EmitManifestAsync) and the manifest is only ever constructed AFTER +// ValidateCounts passes — there is no code path that produces one of these +// with complete: true for a short/partial seed. +public sealed record SimulationManifest( + int SchemaVersion, + int Seed, + int HistoryDays, + DateOnly GeneratedAtAnchor, + SimulationManifestCounts Counts, + SimulationLifecycleStates LifecycleStates, + bool Complete, + string Fingerprint); diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ManifestExpectations.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ManifestExpectations.cs new file mode 100644 index 000000000..549f8f27d --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ManifestExpectations.cs @@ -0,0 +1,205 @@ +using Cluckwork.Infrastructure.Jobs; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Completion manifest (#243 Task 3e) ----------------------------- + // + // The final seeding step: COUNT what was actually created, VALIDATE + // those counts against what the configured SimulationOptions intended to + // create, and (only when CredentialOutputPath is set) WRITE a JSON + // manifest recording the result. This is what closes the "partial seed + // silently looks done" gap — the #243 findings header and #277's + // Playwright suite both read this file and trust `complete: true` outright; + // a short/partial seed must throw HERE instead of quietly producing a + // manifest that says otherwise. (SeedAsync itself returns a SeedResult, not + // the manifest — #279; the manifest is the on-disk artifact, and tests + // point Simulation:CredentialOutputPath at a temp file to read it back.) + // + // Depth-robust by construction: every expectation in ComputeExpectedCounts + // is derived from SimulationOptions (sim.Managers/.../HistoryDays), the + // seeder's own "today"/farm-local-"today" anchors, or this class's own + // structural constants (RecurringStartDay/RecurringCadenceDays, + // DraftWindowDays, GradesPerDailyEntry, FeedUsageDays/WaterUsageDays, the + // hardcoded topology/catalog/expense array lengths) — nothing here + // hardcodes "90 days" or "12 days"; the shallow SimulationSeedFactory test + // fixture (HistoryDays=12) and the real 90-day default validate against + // the SAME formulas. + // + // #638 — that claim covers the EXPECTATIONS, and until #638 the SEEDING + // was not depth-robust to match: the lock sweep ran once, capped at + // DailyEntryLockSweep.BatchSize, so above HistoryDays 107 the Locked + // expectation below could not be met however clean the database. The + // seeding side is DrainLockSweepAsync's job; read the two together before + // widening either claim. + // + // #279 review Fix 2 (Agent A + pi + codex, consensus): every count below + // is asserted EXACTLY, not as a ">= 1" floor — including the + // Draft/Submitted/Locked lifecycle split (Locked is derived by mirroring + // DailyEntryLockSweep's own cutoff — see ExpectedLockedEntryCount below — + // rather than left unchecked; Draft/Submitted then reconcile against the + // total). A bug that drops all-but-one row of any seeder-controlled band + // now fails ValidateCounts instead of still certifying complete: true. + + // Bumped to 2 by #627: the persisted manifest GAINED Customers/BirdMovements + // count fields and a Flocks lifecycle split — a schema-1 reader must + // detect the reshape instead of silently misparsing it. + public const int ManifestSchemaVersion = 2; + + // Mirrors SeedFlockTopologyAsync's `wanted` array length. Kept as an + // independent constant (not read off that array) so ComputeExpectedCounts + // needs no DB round-trip to state what the seed intends to produce — it + // still takes `today`/`farmToday` as plain DateOnly inputs (#279 Fix 2, + // for the exact Locked/Submitted split below) but is otherwise pure. + private const int FlockTopologyCount = 2; + + // #627 — mirrors the picker catalog SeedFlockCatalogAsync creates: + // NumberedCatalogFlockCount is what the numbered loop iterates; the other + // three catalog rows (the page-two sentinel, the Depleted, the Archived) + // are fixed-name rows outside the loop. CatalogFlockBandCount is the + // 100-row catalog total the EXPECTED counts read — kept as an independent + // constant for the same reason FlockTopologyCount is: ComputeExpectedCounts + // states the seed's intent without a DB round-trip. + private const int NumberedCatalogFlockCount = 97; + private const int CatalogFlockBandCount = 100; + + // #627 — the customer band SeedCustomersAsync creates on top of the three + // lifecycle customers in the Sales partial (97 zero-padded fillers + the page-two + // sentinel). Total customers is CustomersWanted.Length + this. + private const int CustomerBandCount = CustomerFillerCount + 1; + + // #627 — mirrors SeedExplicitBirdMovementsAsync's explicit row count. + private const int ExplicitBirdMovementsCount = 51; + + // #627 — mirrors SeedFeedAdjustmentsAsync' added adjustment rows on the + // feed lot (its ten pre-existing movements + this = exactly 120 on the + // feed item; the bedding item keeps its two, so account-wide is 122). + private const int FeedAdjustmentBandCount = 110; + + // #627 — the automatic mortality baseline, derived the SAME way + // SeedFlockHistoryAsync generates it: one row per (operational flock, day) + // where d % 5 == 0 for d in DraftWindowDays+1..historyDays — only days + // that are actually SUBMITTED mint a mortality row. An explicit loop, not + // division: the Draft window is part of the rule, and the expected count + // must stay correct if it changes. (90-day history: 18 per flock, 36 + // account-wide; 12-day: 2 per flock.) + private static int AutomaticMortalityPerFlock(int historyDays) + { + var count = 0; + for (var d = DraftWindowDays + 1; d <= historyDays; d++) + if (d % 5 == 0) count++; + return count; + } + + // Mirrors SeedFlockHistoryAsync's per-entry grade-quantity array literal + // (Large/Medium/Small): SubmitDailyEntryHandler mints exactly one egg lot + // per grade line, and the deterministic baseline/cracked/dirty/discarded + // math in SeedFlockHistoryAsync always leaves all three grade buckets > 0 + // for every configured baseline — so every SUBMITTED entry (never a + // still-Draft one; see DraftWindowDays) produces exactly this many lots, + // not merely "at least one" (#279 Fix 2). + private const int GradesPerDailyEntry = 3; + + // #396: submit ALSO mints a lot for each condition counter that resolved + // to a condition grade (DailyEntryKind.Cracked/Dirty) and carried a + // positive count. Kept separate from GradesPerDailyEntry rather than + // folded into it: these are NOT grade lines — no GradeQuantityDto names + // them, and ConditionGradeGuard actively refuses one that does — so the + // two constants answer different questions and drift for different + // reasons. Both counters are structurally positive here (`cracked = 4 + + // d % 3` >= 4, `dirty = 2 + d % 2` >= 2, for every day and baseline), and + // the base seed ships Cracked/Dirty active + saleable, so both resolve on + // every submitted entry — exactly 2, never "up to 2". If a future fixture + // lets either counter reach 0, SubmitDailyEntryHandler skips that lot and + // this must become a per-entry sum instead of a constant. + private const int ConditionLotsPerDailyEntry = 2; + + // Mirrors SeedSalesAsync's two EnsureDraftOrderAsync calls (customer1/2, + // never confirmed) and its three lifecycle EnsureConfirmedOrderAsync + // calls (customer1/2 unpaid + customer3 partially paid) — the recurring + // drip (SeedRecurringOrdersAsync) is added on top via RecurringPointCount. + private const int DraftOrdersCount = 2; + private const int LifecycleConfirmedOrdersCount = 3; + + // Mirrors SeedExpensesAsync's `wanted` array length (the 4 hardcoded + // fixture expenses) — the recurring drip (SeedRecurringExpensesAsync) is + // added on top via RecurringPointCount, same convention as orders above. + private const int LifecycleExpensesCount = 4; + + // SeedRecurringOrdersAsync and SeedRecurringExpensesAsync both loop + // `for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays)` + // — this is that loop's iteration count, computed once so the manifest's + // expectations never drift from what those two loops actually do. + private static int RecurringPointCount(int historyDays) => + historyDays < RecurringStartDay ? 0 : (historyDays - RecurringStartDay) / RecurringCadenceDays + 1; + + // #638 — DRAIN the sweep; one pass is not enough at depth. + // + // DailyEntryLockSweep is built for the DurableJobWorker: one RunAsync pass + // locks at most DailyEntryLockSweep.BatchSize entries per account and + // returns, and the poll 30 seconds later takes the next batch. A + // run-then-exit seed verb has no poll behind it, so a single pass left + // every eligible entry past the first batch Submitted — while + // ExpectedLockedEntryCount (below) mirrors the sweep's CUTOFF rule and + // therefore expects every eligible entry Locked. Those two agree only + // while `FlockTopologyCount * (HistoryDays - LockAfterDays) <= BatchSize`, + // i.e. HistoryDays <= 107; above it the seed could never validate on any + // database, however clean. + // + // Fixed here rather than by raising BatchSize: that constant is sized for + // the serving-path background worker, and enlarging it to suit a seeder + // would move this ceiling rather than remove it. + // + // Terminates on a pass that locked nothing — the honest "no progress" + // signal (see DailyEntryLockSweep.RunAsync's own contract), which also + // covers the stuck-batch case a due-count terminator would spin on. The + // bound exists only so a future defect that makes a pass report progress + // without making any cannot hang a seed forever; it is derived from the + // same numbers the work itself is, so it can never be the thing that + // fails first on a legitimately deep fixture. + private async Task DrainLockSweepAsync(SimulationOptions sim, CancellationToken ct) + { + var maxPasses = MaxLockSweepPasses(sim.HistoryDays); + for (var pass = 1; pass <= maxPasses; pass++) + if (await lockSweep.RunAsync(ct) == 0) + return; + + throw new InvalidOperationException( + $"Daily-entry lock sweep did not drain in {maxPasses} passes at " + + $"HistoryDays={sim.HistoryDays}; every pass still reported entries locked. " + + "This is a lock-sweep defect, not a seeding depth limit."); + } + + // Upper bound on the passes DrainLockSweepAsync should ever need: enough + // to lock every seeded daily entry across the topology, plus the + // terminating pass that locks nothing, plus one of slack. + // + // Deliberately loose in two directions, because the bound must never be + // the thing that fails first on a legitimate fixture. It counts EVERY + // seeded entry as a candidate while only those past the lock cutoff are + // ever due; and it divides by one BatchSize per pass while a pass sweeps + // every account, so the real per-pass ceiling is higher still. It exists + // solely so a future defect that reports progress without making any + // cannot hang a seed forever. + private static int MaxLockSweepPasses(int historyDays) => + (FlockTopologyCount * Math.Max(historyDays, 0) + DailyEntryLockSweep.BatchSize - 1) + / DailyEntryLockSweep.BatchSize + + 2; + + // Mirrors DailyEntryLockSweep.LockDueEntriesAsync's own cutoff + // (`farmToday.AddDays(-LockAfterDays)`) entry-by-entry, so the manifest's + // expected Locked count is a real derivation from the SAME rule the + // sweep applies — not a ">= 1" floor (#279 Fix 2). Only entries that were + // actually submitted (d > DraftWindowDays) are ever eligible; both flocks + // share the identical day-offset range so one per-flock count scales + // directly by FlockTopologyCount. + private static int ExpectedLockedEntryCount(DateOnly today, DateOnly farmToday, int historyDays) + { + var cutoff = farmToday.AddDays(-DailyEntryLockSweep.LockAfterDays); + var lockedPerFlock = 0; + for (var d = DraftWindowDays + 1; d <= historyDays; d++) + if (today.AddDays(-d) < cutoff) lockedPerFlock++; + return FlockTopologyCount * lockedPerFlock; + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Preflight.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Preflight.cs new file mode 100644 index 000000000..db04b0a0f --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Preflight.cs @@ -0,0 +1,78 @@ +using Cluckwork.Application.Modules.Access.Contracts; +using Cluckwork.Domain.Modules.Farm.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // The three saleable grades SeedFlockHistoryAsync/SeedSalesAsync consume by + // name (grades["Large"/"Medium"/"Small"]). The #283 migration bakes them + // in; preflight requires all three present AND saleable so a schema + // missing one fails loud here instead of throwing KeyNotFoundException + // mid-seed (#279 review Fix 3). + private static readonly string[] RequiredSaleableGrades = ["Large", "Medium", "Small"]; + + // Existence + shape checks, run BEFORE tenant.Resolve — every tenant-scoped + // query needs IgnoreQueryFilters (mirrors DemoDataSeeder's own + // MissingBaseDataAsync). #279 review Fix 3 (codex): each check now PROVES the + // specific base datum this seeder depends on rather than a weaker proxy — + // the exact saleable Large/Medium/Small grades (not merely "any grade"). + // #500 (codex round 4) — this checks the MIGRATION-BAKED base data ONLY. + // The Owner is deliberately no longer folded in: it is a different cause + // with a different remedy (`bootstrap-admin` provisions an Owner and does + // nothing whatever for a renamed grade), and one boolean covering both + // forced the caller to guess which had failed. The caller checks the Owner + // separately, from the single FindOwnerAsync lookup. + private async Task MissingBaseDataAsync(Guid accountId, CancellationToken ct) + { + if (!await farmFixture.AccountExistsAsync(accountId, ct)) return true; + + if (!await access.OwnerRoleExistsAsync(ct)) return true; + + // Tenant is unresolved here, so the fixture reads with the filter off + // and an explicit AccountId (the repository's ListActiveAsync would + // apply the tenant filter and see nothing). + var saleableGradeNames = await eggsFixture.ListSaleableGradeNamesAsync(accountId, ct); + return !RequiredSaleableGrades.All(saleableGradeNames.Contains); + } + + // #500 — the Owner that signs the fixture (slice 1: all of it). + // + // UserManager.GetUsersInRoleAsync is NOT account-scoped — it takes a role + // name and returns Owners across EVERY account — so the AccountId filter is + // load-bearing. Ordered by Id so the choice is deterministic when several + // Owners exist; a fixture whose attribution varied between runs would break + // the convergence contract (#279). Shared with the preflight above so the + // two can never disagree about whether an Owner exists. + // + // DISABLED Owners are excluded for the same reason DemoDataSeeder excludes + // them — the fuller note lives there. Disabling keeps the Owner role row and + // only stamps DisabledAt, so GetUsersInRoleAsync still returns them, and + // signing the fixture with an account that cannot log in makes every History + // line name somebody nobody can sign in as. + // + // Returns the disabled count too — the caller's advice depends on which + // cause it is. See DemoDataSeeder for the full reasoning. + // + // #500 (codex round 5) — and whether the ROLE ITSELF exists, which is a third + // distinct state hiding behind `Owner is null`. `GetUsersInRoleAsync` returns + // an EMPTY LIST for an absent role rather than throwing (that is + // `AddToRoleAsync`/`IsInRoleAsync`; `UserStore.GetUsersInRoleAsync` returns + // empty when FindRoleAsync misses), so a schema whose migration-baked roles + // were dropped is indistinguishable from "nobody has been made an Owner yet" + // — and the two have completely different remedies. `bootstrap-admin` cannot + // repair a missing role; only restoring the base data can. + private async Task<(bool RoleExists, AccessUserSummary? Owner, int DisabledOwners)> FindOwnerAsync( + Guid accountId, CancellationToken ct) + { + if (!await access.OwnerRoleExistsAsync(ct)) return (false, null, 0); + + // #532 — see DemoDataSeeder: scoped at the query, not post-filtered. + var owners = (await access.ListUsersInRoleAsync(accountId, Roles.Owner, ct)).ToList(); + + return (true, + owners.Where(u => u.DisabledAt is null).OrderBy(u => u.Id).FirstOrDefault(), + owners.Count(u => u.DisabledAt is not null)); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ProductionHistory.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ProductionHistory.cs new file mode 100644 index 000000000..4f71f3412 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.ProductionHistory.cs @@ -0,0 +1,138 @@ +using Cluckwork.Application.Modules.EggOperations.Contracts; +using Cluckwork.Domain.Modules.Farm.Contracts; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Production daily-entry history (#243 later task) -------------- + + // One entry per flock per day across Simulation:HistoryDays (or + // MinSentinelAgeDays, whichever is longer — the lock-sweep proof needs + // the sentinel regardless of how short HistoryDays is configured for a + // test). Every date is at least 1 day older than the "today" anchor: + // RecordDailyEntry/CreateFlock validate against the farm's OWN today + // (IFarmClock, #35), which can already read one calendar day behind this + // UTC anchor for a timezone west of UTC depending on time-of-day skew — + // an entry dated exactly "today" could spuriously fail the future-date + // rule. Starting at day 1 keeps every seeded date safely <= the farm's + // today no matter when seeding happens to run. + private async Task SeedProductionHistoryAsync( + Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, + CancellationToken ct) + { + // No ActAs here: SeedFlockHistoryAsync picks a worker per (flock, day) + // via WorkerFor. LoadSaleableGradesAsync below is a read and audits + // nothing. + var grades = await LoadSaleableGradesAsync(ct); + var historyDays = EffectiveHistoryDays(sim); + + for (var i = 0; i < flockIds.Count; i++) + await SeedFlockHistoryAsync( + accountId, flockIds[i], OperationalFlockInitialCounts[i], carriesExplicitAdjustments: i == 0, + today, historyDays, grades, cast, ct); + } + + // #943 — the birds a flock holds at the START of the day `d` days before + // today, mirroring the two removal schedules this seeder writes: one + // automatic mortality on every submitted day whose offset is a multiple + // of five (`SeedFlockHistoryAsync`), and, on the first house only, the + // 51 explicit +1 adjustments of `SeedExplicitBirdMovementsAsync` on the + // offsets 1..63 not divisible by five. Same start-of-day convention as + // the production report: only movements dated strictly before the day + // count. Computed rather than read back because the history loop walks + // newest-first, so the older days' mortality rows do not exist yet when + // a newer day's eggs are decided. + private static int LiveBirdsOnDay(int initialCount, int d, int historyDays, bool carriesExplicitAdjustments) + { + var removed = 0; + for (var earlier = d + 1; earlier <= historyDays; earlier++) + if (earlier > DraftWindowDays && earlier % 5 == 0) removed++; + if (carriesExplicitAdjustments) + for (var earlier = d + 1; earlier <= ExplicitBirdMovementLastOffset; earlier++) + if (earlier % 5 != 0) removed++; + return initialCount - removed; + } + + // Live birds at an 86–94% hen-day rate that varies day to day. A hen lays + // at most one egg a day; the former constant baseline (380 eggs from 350 + // birds) rated every day of the Dashboard and Reports over 100% (#943). + // Deterministic variation — no Random, reproducible seeds. + private static int EggsOnDay(int liveBirds, int d) => liveBirds * (86 + (d * 7) % 9) / 100; + + // Shared by production history (above) and the sales catalog (#243 Task + // 3b) — both need the same saleable-grade lookup, keyed by name. + private async Task> LoadSaleableGradesAsync(CancellationToken ct) + { + var grades = (await eggs.ListActiveGradesAsync(SeedDefaults.FarmId, ct)) + .Where(g => g.IsSaleable) + .ToDictionary(g => g.Name, g => g.Id); + if (grades.Count == 0) + throw new InvalidOperationException("Simulation seed needs the default egg grades."); + return grades; + } + + private async Task SeedFlockHistoryAsync( + Guid accountId, Guid flockId, int initialCount, bool carriesExplicitAdjustments, + DateOnly today, int historyDays, + IReadOnlyDictionary grades, SimCast cast, CancellationToken ct) + { + for (var d = 1; d <= historyDays; d++) + { + var date = today.AddDays(-d); + + // Idempotent re-run: RecordDailyEntryHandler itself only accepts + // edits to Draft entries, so a plain re-call would throw once the + // sentinel is Submitted/Locked — skip on existence instead + // (mirrors EnsureUserAsync/EnsureFlockAsync in the Cast and Flocks partials). + if (await eggsFixture.DefaultHouseEntryExistsAsync(accountId, flockId, date, ct)) + continue; + + var total = EggsOnDay(LiveBirdsOnDay(initialCount, d, historyDays, carriesExplicitAdjustments), d); + var cracked = 4 + d % 3; + var dirty = 2 + d % 2; + const int discarded = 1; + var mortality = d % 5 == 0 ? 1 : 0; + var sellable = total - cracked - dirty - discarded; + var large = sellable * 55 / 100; + var medium = sellable * 30 / 100; + var small = sellable - large - medium; + + // #500 — a worker records the day's eggs. Placed AFTER the + // natural-key skip above so a converged re-run neither picks nor + // resolves anyone. + ActAs(WorkerFor(cast, flockId, d)); + + var recorded = await eggs.RecordDailyEntryAsync(new RecordDailyEntryCommand( + SeedDefaults.FarmId, SeedDefaults.HouseId, flockId, date, + total, cracked, dirty, discarded, mortality, + [ + new GradeQuantityDto(grades["Large"], large), + new GradeQuantityDto(grades["Medium"], medium), + new GradeQuantityDto(grades["Small"], small), + ]), accountId, ct); + Require(recorded, $"record daily entry for flock {flockId} on {date:yyyy-MM-dd}"); + var entryId = recorded.Value; + + // The most recent DraftWindowDays days stay Draft; everything + // older (including the day-9 sentinel) is submitted. + if (d > DraftWindowDays) + { + // #500 — both provenance shapes. Every SubmittedByManagerEvery-th + // day a manager signs the entry off, so its History line reads + // "created by X, last changed by Y"; otherwise the recording + // worker stays resolved and submits their own, which reads + // "created and last changed by the same person". + if (d % SubmittedByManagerEvery == 0) + ActAs(Pick(cast.Managers, d, cast.Owner, "Managers")); + + var submitted = await eggs.SubmitDailyEntryAsync(entryId, accountId, ct); + Require(submitted, $"submit daily entry {entryId} for flock {flockId} on {date:yyyy-MM-dd}"); + } + + // Every handler has saved; later days reload by id, so release the + // day's entities instead of rescanning the whole history (#1045). + db.ChangeTracker.Clear(); + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Sales.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Sales.cs new file mode 100644 index 000000000..210ef07b9 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.Sales.cs @@ -0,0 +1,345 @@ +using Cluckwork.Application.Modules.Commerce.Contracts; + +namespace Cluckwork.Infrastructure.Persistence; + +public sealed partial class SimulationDataSeeder +{ + // --- Sales: catalog, customers, orders across the lifecycle, FIFO + // depletion, payments (#243 Task 3b) ------------------------------------ + // + // Everything below runs through the SAME real handlers DemoDataSeeder + // uses (CreateProduct/CreateCustomer/CreateSalesOrder/AddOrderItem/ + // ConfirmSale/RecordPayment) — orders confirmed here allocate FIFO + // against the graded lots the production history in the ProductionHistory partial just generated, + // exactly as a user clicking through the Sales screen would. Idempotency + // is per-entity existence checks (product/customer by Name, order by the + // (CustomerId, OrderDate) natural key this seeder controls), same style + // as EnsureUserAsync/EnsureFlockAsync in the Cast and Flocks partials. + + // One product per saleable grade, sold in individual eggs (factor 1) — + // keeps order-item quantities directly comparable to lot sizes, which is + // what makes the FIFO-contention shape below easy to reason about. + // + // #396: Cracked/Dirty are here for a different reason than the graded + // three. Submit now mints a lot for each condition counter, so without a + // product mapped to those grades the fixture accumulates stock that can + // never leave — ~180 lots of dead inventory the sales screens can see and + // nothing can sell. Priced below Small, which is the point of the feature + // (a cracked egg is saleable, at a discount). Nothing here orders them; + // the FIFO-contention shape above stays a Large/Medium story. + private static readonly (string GradeName, string ProductName, long PriceMinorUnits)[] CatalogWanted = + [ + ("Large", "Sim Large Eggs", 45), + ("Medium", "Sim Medium Eggs", 38), + ("Small", "Sim Small Eggs", 30), + ("Cracked", "Sim Cracked Eggs", 18), + ("Dirty", "Sim Dirty Eggs", 22), + ]; + + // #720 — no default price at all, distinct from a below/above-list line: + // this is the "no comparable list price" state neither of those shows. + // Kept out of CatalogWanted/products (that dictionary is keyed by GRADE + // NAME): mapping it to "Large" like Sim Large Eggs would silently + // overwrite that key. No uniqueness constraint ties a grade to one + // product (ProductEggGradeMappingConfiguration only unique-indexes + // ProductId), so sharing the grade is fine. + private const string UnpricedProductName = "Sim Unpriced Eggs"; + + private static readonly (string Name, string Phone, string Note)[] CustomersWanted = + [ + ("Sim Customer 1", "555-0201", "Simulation fixture customer"), + ("Sim Customer 2", "555-0202", "Simulation fixture customer"), + ("Sim Customer 3", "555-0203", "Simulation fixture customer"), + ]; + + // #627 — over-cap customer band: the three lifecycle rows above stay the + // sales-order customers; 97 zero-padded fillers plus the lexically-last + // page-two sentinel bring the account total to exactly 101, so the + // customer list's 100-row page cannot reach the sentinel without Load more. + private const int CustomerFillerCount = 97; + private const string CustomerPageTwoSentinelName = "Sim Customer Z Page Two"; + + // Modest relative to a single day's Large lot (baseline 320+ eggs/day, + // ~55% Large) — small enough that every confirmed order below draws from + // the SAME shallow pool of the oldest available Large lots (FIFO always + // starts at the oldest ProductionDate) instead of each landing on + // disjoint fresh stock. That shared-pool shape is what a later hazard + // task needs to force FOR UPDATE lock contention; this task only sets + // the baseline shape up and never forces the contention itself. + private const int ConfirmedOrderQuantityEggs = 36; + private const int DraftOrderQuantityEggs = 24; + + // #243 Task 3d — see the class header's depth/density decision. This + // second series never touches days 1-7 (every hardcoded date above), + // starts at MinSentinelAgeDays (9, so it survives even a HistoryDays + // shorter than that floor), and repeats every RecurringCadenceDays out to + // the effective history length — spreading confirmed orders across the + // WHOLE window instead of leaving them clustered in the most recent week. + private const int RecurringStartDay = MinSentinelAgeDays; + private const int RecurringCadenceDays = 7; + private const int RecurringOrderQuantityEggs = 12; + + private async Task SeedSalesAsync( + Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) + { + var grades = await LoadSaleableGradesAsync(ct); + // The catalog is a manager's to define; the orders against it are the + // sales desk's (#500). Product.Create and SalesOrder.* are all audited. + var products = await SeedProductCatalogAsync( + accountId, grades, Pick(cast.Managers, 0, cast.Owner, "Managers"), ct); + var unpricedProductId = await EnsureProductAsync( + accountId, UnpricedProductName, grades["Large"], null, + Pick(cast.Managers, 0, cast.Owner, "Managers"), ct); + var customerIds = await SeedCustomersAsync(accountId, cast, ct); + + var largeProductId = products["Large"]; + var mediumProductId = products["Medium"]; + var smallProductId = products["Small"]; + var customer1 = customerIds[0]; + var customer2 = customerIds[1]; + var customer3 = customerIds[2]; + + // Each order carries its own index into the Sales pool: these five call + // sites have no loop of their own, so the CALLER owns the rotation + // rather than a helper inventing one. + SimActor Clerk(int index) => Pick(cast.Sales, index, cast.Owner, "Sales"); + + // Draft: created, items added, never confirmed — no stock touched. + var draft1Id = await EnsureDraftOrderAsync( + accountId, customer1, today.AddDays(-4), mediumProductId, DraftOrderQuantityEggs, Clerk(0), ct); + var draft2Id = await EnsureDraftOrderAsync( + accountId, customer2, today.AddDays(-3), mediumProductId, DraftOrderQuantityEggs, Clerk(1), ct); + + // #720 — every line seeded above sells at the product's own default + // (both add-item call sites pass null/null), so three of the four + // render states (below list, above list, no list price) were never + // seeded. These land on the two EXISTING drafts, not new orders, so + // SimulationDataSeeder's own order/line count manifest (which tracks + // orders, never lines or products) stays untouched. + // + // #721 — the BELOW-list line must stay on a draft. Confirming an order + // that carries one now requires a discount reason, and the seeders build + // ConfirmSaleCommand directly with none, so moving this onto an order + // EnsureConfirmedOrderAsync touches would fail the seed at runtime in a + // verb nothing in CI runs. + await EnsureExtraLineAsync( + accountId, draft1Id, largeProductId, DraftOrderQuantityEggs, 35, Clerk(0), ct); // list 45 → below + await EnsureExtraLineAsync( + accountId, draft2Id, smallProductId, DraftOrderQuantityEggs, 40, Clerk(1), ct); // list 30 → above + await EnsureExtraLineAsync( + accountId, draft2Id, unpricedProductId, DraftOrderQuantityEggs, 25, Clerk(1), ct); // no list price + + // Confirmed (unpaid): same product/grade as every other confirmed + // order below — see ConfirmedOrderQuantityEggs for why that matters. + await EnsureConfirmedOrderAsync( + accountId, customer1, today.AddDays(-6), largeProductId, ConfirmedOrderQuantityEggs, Clerk(2), ct); + await EnsureConfirmedOrderAsync( + accountId, customer2, today.AddDays(-5), largeProductId, ConfirmedOrderQuantityEggs, Clerk(3), ct); + + // Confirmed + partially paid — also left un-voided, so it doubles as + // the "voidable confirmed order" a later hazard pass can void. + var partialOrderId = await EnsureConfirmedOrderAsync( + accountId, customer3, today.AddDays(-2), largeProductId, ConfirmedOrderQuantityEggs, Clerk(4), ct); + await EnsurePartialPaymentAsync(accountId, partialOrderId, today.AddDays(-1), Clerk(4), ct); + + await SeedRecurringOrdersAsync(accountId, today, customerIds, largeProductId, cast, sim, ct); + } + + // Spreads a modest, deterministic drip of additional confirmed orders + // across the full history window — see RecurringStartDay/RecurringCadenceDays + // above. Always the Large product: keeps it inside the same FIFO + // shared-old-lot-pool shape ConfirmedOrderQuantityEggs already documents, + // and every date here is <= EffectiveHistoryDays, so production history + // (seeded before sales) always has a lot on or before that date to + // allocate from. + private async Task SeedRecurringOrdersAsync( + Guid accountId, DateOnly today, IReadOnlyList customerIds, Guid largeProductId, + SimCast cast, SimulationOptions sim, CancellationToken ct) + { + var historyDays = EffectiveHistoryDays(sim); + var i = 0; + for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays, i++) + { + var customerId = customerIds[i % customerIds.Count]; + await EnsureConfirmedOrderAsync( + accountId, customerId, today.AddDays(-d), largeProductId, RecurringOrderQuantityEggs, + Pick(cast.Sales, i, cast.Owner, "Sales"), ct); + } + } + + private async Task> SeedProductCatalogAsync( + Guid accountId, IReadOnlyDictionary grades, SimActor actor, CancellationToken ct) + { + var products = new Dictionary(); + foreach (var (gradeName, productName, price) in CatalogWanted) + { + if (!grades.TryGetValue(gradeName, out var gradeId)) + throw new InvalidOperationException( + $"Simulation seed needs the '{gradeName}' egg grade for the sales catalog."); + products[gradeName] = await EnsureProductAsync(accountId, productName, gradeId, price, actor, ct); + } + return products; + } + + private async Task EnsureProductAsync( + Guid accountId, string name, Guid eggGradeId, long? priceMinorUnits, SimActor actor, CancellationToken ct) + { + if (await commerceFixture.FindProductIdByNameAsync(name, ct) is Guid existingId) return existingId; + + ActAs(actor); + var result = await commerce.CreateProductAsync(new CreateProductCommand( + name, "Egg", "Egg", priceMinorUnits, eggGradeId, "Simulation fixture product"), accountId, ct); + Require(result, $"create product {name}"); + return result.Value; + } + + // Customer.Create is audited as the Sales actor declared per customer here, + // for consistency with the orders these customers receive. + private async Task> SeedCustomersAsync( + Guid accountId, SimCast cast, CancellationToken ct) + { + var ids = new List(); + for (var i = 0; i < CustomersWanted.Length; i++) + { + var (name, phone, note) = CustomersWanted[i]; + ids.Add(await EnsureCustomerAsync( + accountId, name, phone, note, Pick(cast.Sales, i, cast.Owner, "Sales"), ct)); + } + + // #627 — the over-cap fillers and the lexically-last page-two sentinel. + // The three lifecycle customers above are the only ones orders touch; + // these rows are list volume, created through the same handler with + // the same name-probe idempotency. + for (var i = 1; i <= CustomerFillerCount; i++) + await EnsureCustomerAsync( + accountId, + $"Sim Customer Filler {i:000}", + $"555-03{i:00}", + "Simulation fixture customer", + Pick(cast.Sales, i, cast.Owner, "Sales"), + ct); + + await EnsureCustomerAsync( + accountId, + CustomerPageTwoSentinelName, + "555-0299", + "Simulation fixture customer", + Pick(cast.Sales, CustomerFillerCount + 1, cast.Owner, "Sales"), + ct); + + return ids; + } + + private async Task EnsureCustomerAsync( + Guid accountId, string name, string phone, string note, SimActor actor, CancellationToken ct) + { + if (await commerceFixture.FindCustomerIdByNameAsync(name, ct) is Guid existingId) return existingId; + + ActAs(actor); + var result = await commerce.CreateCustomerAsync( + new CreateCustomerCommand(name, phone, Note: note), accountId, ct); + Require(result, $"create customer {name}"); + return result.Value; + } + + // (CustomerId, OrderDate) is the natural key THIS seeder controls (every + // call site below uses a distinct date per customer) — SalesOrder itself + // has no other stable, human-chosen identity to check idempotency against + // (ReferenceNumber is minted from a random order id inside the handler). + private Task FindOrderAsync(Guid customerId, DateOnly orderDate, CancellationToken ct) => + commerceFixture.FindOrderIdAsync(customerId, orderDate, ct); + + private async Task EnsureDraftOrderAsync( + Guid accountId, Guid customerId, DateOnly orderDate, Guid productId, int quantityEggs, + SimActor actor, CancellationToken ct) + { + var existing = await FindOrderAsync(customerId, orderDate, ct); + if (existing is not null) return existing.Value; + + ActAs(actor); + var created = await commerce.CreateSalesOrderAsync( + new CreateSalesOrderCommand(customerId, orderDate), accountId, ct); + Require(created, $"create sales order for customer {customerId} on {orderDate:yyyy-MM-dd}"); + var orderId = created.Value; + + // Unit/price both null: defaults from the product (Egg unit, the + // catalog price seeded above) — same pattern as DemoDataSeeder. + var added = await commerce.AddOrderItemAsync( + new AddOrderItemCommand(orderId, productId, quantityEggs, null, null), accountId, ct); + Require(added, $"add item to sales order {orderId}"); + + return orderId; + } + + // #720 — a SECOND line on an already-seeded draft, at an EXPLICIT price so + // the fixture can show a discounted, an above-list, and an unpriced-product + // line (every seeded line was at-list before this, since both add-item call + // sites passed null/null). Idempotent on (SalesOrderId, ProductId): this + // order's base line already occupies its own product, so a distinct + // product per extra line is what keeps a rerun from re-adding it. + private async Task EnsureExtraLineAsync( + Guid accountId, Guid orderId, Guid productId, int quantityEggs, long explicitPriceMinorUnits, + SimActor actor, CancellationToken ct) + { + if (await commerceFixture.OrderHasLineAsync(orderId, productId, ct)) return; + + ActAs(actor); + var added = await commerce.AddOrderItemAsync( + new AddOrderItemCommand(orderId, productId, quantityEggs, null, explicitPriceMinorUnits), + accountId, ct); + Require(added, $"add extra line to sales order {orderId}"); + } + + private async Task EnsureConfirmedOrderAsync( + Guid accountId, Guid customerId, DateOnly orderDate, Guid productId, int quantityEggs, + SimActor actor, CancellationToken ct) + { + var orderId = await EnsureDraftOrderAsync( + accountId, customerId, orderDate, productId, quantityEggs, actor, ct); + + // Status, not "the order already existed", decides whether to + // confirm — an order that exists but is still Draft still needs + // confirming on a re-run. + if (await commerceFixture.IsOrderConfirmedAsync(orderId, ct)) return orderId; + + // Re-declared rather than inherited from EnsureDraftOrderAsync: on the + // re-run path above, that call returns early without acting at all, so + // the resolved actor would be whoever the previous phase left behind — + // a manager, from the inventory phase — and the confirmation would be + // authorized and audited as them. + // + // NOT COVERED BY ANY TEST, stated rather than left to be assumed (#500 + // mid-point review; confirmed by mutation — deleting this line leaves + // the whole suite green). Reaching it needs a fixture where a DRAFT + // order survives into a re-run, and the only way to build one is to + // force a confirmed order back to Draft, which replays FIFO allocation + // against the same lots. The line is correct and cheap; the fixture + // that would prove it is neither. + ActAs(actor); + // FIFO allocation (tech spec §10.9.1): draws from the oldest + // available lots for this grade under a FOR UPDATE lock — this is + // what depletes the production history's egg lots. + var confirmed = await commerce.ConfirmSaleAsync(new ConfirmSaleCommand(orderId), accountId, actor.UserId, ct); + Require(confirmed, $"confirm sales order {orderId}"); + return orderId; + } + + // Payment.* is audited by nothing — same standing as customers above. + private async Task EnsurePartialPaymentAsync( + Guid accountId, Guid orderId, DateOnly paymentDate, SimActor actor, CancellationToken ct) + { + if (await commerceFixture.PaymentExistsAsync(orderId, ct)) return; + + // Half the total, rounded down — strictly less than TotalAmount so + // the order stays genuinely partially paid, never fully settled. + var amount = await commerceFixture.GetOrderTotalMinorUnitsAsync(orderId, ct) / 2; + if (amount <= 0) + throw new InvalidOperationException( + $"Simulation seed: sales order {orderId}'s total is too small to seed a partial payment."); + + ActAs(actor); + var result = await commerce.RecordPaymentAsync(new RecordPaymentCommand( + orderId, paymentDate, amount, "Cash", null, "Simulation fixture partial payment"), accountId, ct); + Require(result, $"record partial payment for sales order {orderId}"); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs index b3a7dec36..cb458e9e1 100644 --- a/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs +++ b/src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs @@ -6,10 +6,6 @@ using Cluckwork.Application.Modules.FlockManagement.Contracts; using Cluckwork.Application.Modules.GeneralInventory.Contracts; using Cluckwork.Domain.Modules.Farm.Contracts; -using Cluckwork.Domain.Modules.FlockManagement.Contracts; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; using Cluckwork.Application.Common; using Cluckwork.Domain.Common; using Cluckwork.Infrastructure.Jobs; @@ -52,7 +48,7 @@ namespace Cluckwork.Infrastructure.Persistence; // fixtures (draft/confirmed/partially-paid orders, the 4 expenses) all sat // inside the most recent ~week of history, so a sales/expense/profit report // or an export request against an OLDER slice of the window saw nothing. -// SeedRecurringOrdersAsync/SeedRecurringExpensesAsync below fix that with a +// SeedRecurringOrdersAsync/SeedRecurringExpensesAsync in the Sales and Inventory partials fix that with a // second, independent, deterministic drip of confirmed orders and expenses // spread every RecurringCadenceDays days across the WHOLE window (starting at // MinSentinelAgeDays so at least one recurring point survives even a @@ -63,7 +59,7 @@ namespace Cluckwork.Infrastructure.Persistence; // FeedUsageDays/WaterUsageDays (4) — there's no report endpoint over that // data, only a flat export dataset, and it was already non-empty; widening it // would be bloat the task didn't ask for. -public sealed class SimulationDataSeeder( +public sealed partial class SimulationDataSeeder( AppDbContext db, TenantContext tenant, CurrentUserContext currentUser, @@ -100,7 +96,7 @@ public sealed class SimulationDataSeeder( // regardless of how short Simulation:HistoryDays is configured. private const int MinSentinelAgeDays = DailyEntryLockSweep.LockAfterDays + 2; - // Shared by production history and the inventory opening purchase below + // Shared by production history and the inventory opening purchase in the ProductionHistory and Inventory partials // (#243 Task 3c) — both need "how many days of history actually exist" // to anchor dates safely before it, regardless of how short // Simulation:HistoryDays is configured for a test. @@ -130,7 +126,7 @@ public sealed class SimulationDataSeeder( // #279: the command result (Seeded/AlreadySeeded/PrerequisitesMissing/ // Failed) is now SEPARATE from the completion manifest artifact — the // manifest is still computed/validated and (when CredentialOutputPath is - // configured) written to disk by EmitManifestAsync below exactly as + // configured) written to disk by EmitManifestAsync in the Manifest partial exactly as // before, but SeedAsync's return value no longer carries it; a caller // that needs the manifest's contents reads the file (or, in a test with // no CredentialOutputPath configured, there is none to read — point it at @@ -407,1470 +403,6 @@ public async Task SeedAsync(CancellationToken ct = default) } } - - // The three saleable grades SeedFlockHistoryAsync/SeedSalesAsync consume by - // name (grades["Large"/"Medium"/"Small"]). The #283 migration bakes them - // in; preflight requires all three present AND saleable so a schema - // missing one fails loud here instead of throwing KeyNotFoundException - // mid-seed (#279 review Fix 3). - private static readonly string[] RequiredSaleableGrades = ["Large", "Medium", "Small"]; - - // Existence + shape checks, run BEFORE tenant.Resolve — every tenant-scoped - // query needs IgnoreQueryFilters (mirrors DemoDataSeeder's own - // MissingBaseDataAsync). #279 review Fix 3 (codex): each check now PROVES the - // specific base datum this seeder depends on rather than a weaker proxy — - // the exact saleable Large/Medium/Small grades (not merely "any grade"). - // #500 (codex round 4) — this checks the MIGRATION-BAKED base data ONLY. - // The Owner is deliberately no longer folded in: it is a different cause - // with a different remedy (`bootstrap-admin` provisions an Owner and does - // nothing whatever for a renamed grade), and one boolean covering both - // forced the caller to guess which had failed. The caller checks the Owner - // separately, from the single FindOwnerAsync lookup. - private async Task MissingBaseDataAsync(Guid accountId, CancellationToken ct) - { - if (!await farmFixture.AccountExistsAsync(accountId, ct)) return true; - - if (!await access.OwnerRoleExistsAsync(ct)) return true; - - // Tenant is unresolved here, so the fixture reads with the filter off - // and an explicit AccountId (the repository's ListActiveAsync would - // apply the tenant filter and see nothing). - var saleableGradeNames = await eggsFixture.ListSaleableGradeNamesAsync(accountId, ct); - return !RequiredSaleableGrades.All(saleableGradeNames.Contains); - } - - // #500 — the Owner that signs the fixture (slice 1: all of it). - // - // UserManager.GetUsersInRoleAsync is NOT account-scoped — it takes a role - // name and returns Owners across EVERY account — so the AccountId filter is - // load-bearing. Ordered by Id so the choice is deterministic when several - // Owners exist; a fixture whose attribution varied between runs would break - // the convergence contract (#279). Shared with the preflight above so the - // two can never disagree about whether an Owner exists. - // - // DISABLED Owners are excluded for the same reason DemoDataSeeder excludes - // them — the fuller note lives there. Disabling keeps the Owner role row and - // only stamps DisabledAt, so GetUsersInRoleAsync still returns them, and - // signing the fixture with an account that cannot log in makes every History - // line name somebody nobody can sign in as. - // - // Returns the disabled count too — the caller's advice depends on which - // cause it is. See DemoDataSeeder for the full reasoning. - // - // #500 (codex round 5) — and whether the ROLE ITSELF exists, which is a third - // distinct state hiding behind `Owner is null`. `GetUsersInRoleAsync` returns - // an EMPTY LIST for an absent role rather than throwing (that is - // `AddToRoleAsync`/`IsInRoleAsync`; `UserStore.GetUsersInRoleAsync` returns - // empty when FindRoleAsync misses), so a schema whose migration-baked roles - // were dropped is indistinguishable from "nobody has been made an Owner yet" - // — and the two have completely different remedies. `bootstrap-admin` cannot - // repair a missing role; only restoring the base data can. - private async Task<(bool RoleExists, AccessUserSummary? Owner, int DisabledOwners)> FindOwnerAsync( - Guid accountId, CancellationToken ct) - { - if (!await access.OwnerRoleExistsAsync(ct)) return (false, null, 0); - - // #532 — see DemoDataSeeder: scoped at the query, not post-filtered. - var owners = (await access.ListUsersInRoleAsync(accountId, Roles.Owner, ct)).ToList(); - - return (true, - owners.Where(u => u.DisabledAt is null).OrderBy(u => u.Id).FirstOrDefault(), - owners.Count(u => u.DisabledAt is not null)); - } - - // --- Cast: Managers, Sales, ReadOnly, Workers (role-less) --------- - - // #500 — one seeded persona, carrying everything the acting context needs. - // - // Roles is NOT a label. FlockScopeGuard reads it (via CurrentUserContext, - // which stores what ActAs passes it verbatim) and lets Owner/Manager through - // by role alone, so a SimActor built with the wrong roles changes what the - // fixture is ALLOWED to write, not merely whose name lands on the audit row. - private sealed record SimActor(Guid UserId, string Email, IReadOnlyList Roles); - - // The personas the fixture acts as. ReadOnly is deliberately absent: those - // users are created (the load test signs in as them) but no phase ever picks - // one, which is what makes "a ReadOnly persona authors nothing" a property of - // this type rather than of a rule someone has to remember. - // - // The restricted pair is Guid.Empty until RestrictOneWorkerAsync reports it, - // which is why every phase that needs it runs after that call. - private sealed record SimCast( - SimActor Owner, - IReadOnlyList Managers, - IReadOnlyList Sales, - IReadOnlyList Workers, - Guid RestrictedWorkerId, - Guid RestrictedFlockId); - - // Rotates deterministically through a pool. TOTAL for every pool, including - // an empty one: SimulationOptions validates no count, so Managers/Sales/ - // Workers may each be 0 and `pool[index % pool.Count]` would divide by zero. - // - // The fallback is a fidelity DEGRADATION, so it is never silent (#500 review - // round 2, finding 6) — hiding it would be the same class of defect #500 - // reports. Failing instead would be worse: Workers == 0 is already a - // deliberately tolerated configuration (RestrictOneWorkerAsync warns and - // carries on). - private SimActor Pick(IReadOnlyList pool, int index, SimActor fallback, string poolName) => - Pick(pool, index, () => fallback, poolName); - - // Deferred-fallback overload, and the reason it exists: WorkerFor nests one - // Pick inside another, and C# evaluates the inner call eagerly as an - // ARGUMENT. Through the overload above, that ran the manager-pool Pick on - // every production day and — at Managers == 0 — logged "provenance is - // degraded" for each of them, even though a worker was available and - // nothing had been substituted. A warning that fires when the thing it - // warns about did not happen is worse than no warning. (#500 mid-point - // review.) - private SimActor Pick(IReadOnlyList pool, int index, Func fallback, string poolName) - { - if (pool.Count > 0) return pool[index % pool.Count]; - - var substitute = fallback(); - logger.LogWarning( - "Simulation cast pool {Pool} is empty — rows a {Pool} persona would have authored are " + - "attributed to {Fallback} instead. The seeded fixture's provenance is degraded.", - poolName, poolName, substitute.Email); - return substitute; - } - - // Declares who the following handler calls act as. A pure assignment: - // CurrentUserContext.Resolve stores all three values verbatim — no database - // read, no role re-fetch — so the roles passed here are exactly the ones - // FlockScopeGuard reads. - private void ActAs(SimActor actor) => currentUser.Resolve(actor.UserId, actor.Email, actor.Roles); - - private async Task SeedCastAsync( - Guid accountId, SimActor owner, SimulationOptions sim, CancellationToken ct) - { - var managers = new List(); - for (var i = 1; i <= sim.Managers; i++) - managers.Add(await EnsureUserAsync( - accountId, $"sim-manager-{i}@{sim.EmailDomain}", Roles.Manager, - $"Sim Manager {i}", sim.CastPassword, ct)); - - var sales = new List(); - for (var i = 1; i <= sim.Sales; i++) - sales.Add(await EnsureUserAsync( - accountId, $"sim-sales-{i}@{sim.EmailDomain}", Roles.Sales, - $"Sim Sales {i}", sim.CastPassword, ct)); - - // Created, never picked — see SimCast. The load test signs in as these - // to exercise read-only routes; the fixture itself never acts as one. - for (var i = 1; i <= sim.ReadOnly; i++) - await EnsureUserAsync( - accountId, $"sim-readonly-{i}@{sim.EmailDomain}", Roles.ReadOnly, - $"Sim ReadOnly {i}", sim.CastPassword, ct); - - // Workers deliberately carry no role row (Roles.cs) — the seeder's own - // storedRole conversion below maps WorkerRole to a null role. - var workers = new List(); - for (var i = 1; i <= sim.Workers; i++) - workers.Add(await EnsureUserAsync( - accountId, $"sim-worker-{i}@{sim.EmailDomain}", WorkerRole, - $"Sim Worker {i}", sim.CastPassword, ct)); - - return new SimCast(owner, managers, sales, workers, Guid.Empty, Guid.Empty); - } - - // Which worker records a given flock's eggs on a given day. - // - // This is an AUTHORIZATION rule, not a fidelity nicety. RecordDailyEntry and - // SubmitDailyEntry both run FlockScopeGuard, which reads the very context - // ActAs writes; the one worker RestrictOneWorkerAsync narrowed carries a - // UserRoleAssignment row, so picking it for any other flock returns - // FlockScope.NotAssigned and fails the entire seed. Workers with no - // assignment row stay account-wide (the guard grandfathers them). - // - // Eligibility is therefore per-flock, and the counts differ: at the default - // cast the restricted flock has all 3 workers eligible and every other flock - // has 2. - private SimActor WorkerFor(SimCast cast, Guid flockId, int dayIndex) - { - var eligible = flockId == cast.RestrictedFlockId - ? cast.Workers - // Materialized because Pick takes an IReadOnlyList. - : [.. cast.Workers.Where(w => w.UserId != cast.RestrictedWorkerId)]; - - // Total by construction: an empty worker pool falls back to a manager, - // and an empty manager pool falls back to the Owner, who always exists - // (MissingBaseDataAsync refuses to seed without one). Both fallbacks - // warn — and the manager one is DEFERRED, so it warns only when the - // worker pool was actually empty. See the Pick overloads. - return Pick( - eligible, dayIndex, - () => Pick(cast.Managers, dayIndex, cast.Owner, "Managers"), - "Workers"); - } - - // #500 — returns the persona, not just its id. - // - // Both branches read actual Roles through Access, rather - // than the create branch trusting the role name it just passed in. That is - // deliberate: a persona rebuilt differently on a re-run than on a first run - // is exactly the silent divergence this fixture's determinism contract - // (#279) exists to prevent, and Roles is an authorization input. - private async Task EnsureUserAsync( - Guid accountId, string email, string role, string name, string password, CancellationToken ct) - { - // #532 — ACCOUNT-SCOPED, and this is the one that mattered. The global - // lookup adopted ANOTHER farm's user as this account's persona when only - // a foreign row existed, and threw outright once both farms had one - // (UserStore backs FindByEmailAsync with SingleOrDefaultAsync). - // SimActor.Roles is an authorization input (#500), so a mis-attributed - // persona is a tenant-isolation defect, not a cosmetic one. - var existing = await access.FindUserByEmailAsync(accountId, email, ct); - if (existing is null) - { - // #360 — the simulation command is a trusted, non-HTTP one-shot - // caller. Interactive creation is fail-closed in CreateUserHandler; - // this seeder uses Access fixture creation explicitly while - // acting as the real Owner resolved by SeedAsync. There is no flag, - // magic actor id, environment branch, or request-selectable bypass. - var storedRole = role == WorkerRole ? null : role; - var result = await operations.CreateUserAsync( - accountId, email.Trim(), password, storedRole, - name: UserName.Normalize(name), ct: ct); - Require(result, $"create cast user {email}"); - - existing = await access.FindUserByEmailAsync(accountId, email, ct) - ?? throw new InvalidOperationException( - $"Simulation seed: cast user {email} was created but cannot be read back."); - } - - var roles = existing.Roles; - - // #500 (codex round 4) — a DISABLED cast member is refused, for exactly - // the reason FindOwnerAsync refuses a disabled Owner one screen up. - // - // This only bites on a PARTIAL RE-RUN: the persona was created by an - // earlier run, somebody disabled it through the Users screen, and this - // run finds it by email. Disabling stamps DisabledAt and keeps every - // role row, so FindByEmailAsync returns it and the role check below - // passes — the seeder would then attribute newly written fixture history - // to a persona LoginAsync rejects (IdentityProvider line 41). The seed - // reports success and the fixture is unusable in the one way nobody - // checks, because the rows look perfectly well-formed. - if (existing.DisabledAt is not null) - throw new InvalidOperationException( - $"Simulation seed: cast user {email} exists but is DISABLED, so the fixture would name an " + - "actor who cannot log in. Re-enable that user from the Users screen (which clears both " + - "DisabledAt and DisabledBy), or reset the fixture."); - - // The persona must still HOLD the role this call site is staffing, or - // the fixture is not what it says it is (#500 final review, raised - // independently by two reviewers). - // - // Fetching the roles rather than fabricating them is right, but it means - // an existing user whose role was changed out from under the seeder is - // silently accepted into the wrong pool. A `sim-manager-1` demoted to - // ReadOnly would go on authoring the manager phases — and, holding no - // UserRoleAssignment rows, would sail through FlockScopeGuard on its - // "zero rows = account-wide" branch rather than being refused. The seed - // would then fail its exact-count validation much later, AFTER durable - // writes, with a message pointing at counts instead of at the cause. - // - // Fail here instead, naming both roles. - // - // #500 (codex round 4) — the Worker case is checked, not EXEMPT. "Worker" - // is a pseudo-role the seeder's storedRole conversion maps to null, so a worker holds no - // assignable role — which means the correct assertion for a worker is - // `roles is empty`, not "skip the check". Exempting it let a sim-worker - // promoted to Manager through the Users UI pass as a worker, and that is - // the worst version of this bug rather than a cosmetic one: the whole - // restricted-worker shape this fixture exists to exercise is a - // FlockScopeGuard narrowing, and Manager BYPASSES that guard by role. The - // rerun would go green having tested the opposite of what it claims. - if (role == WorkerRole) - { - if (roles.Count > 0) - throw new InvalidOperationException( - $"Simulation seed: cast user {email} is the fixture's WORKER persona, which must hold no " + - $"assignable role, but it holds: {string.Join(", ", roles)}. A worker promoted outside the " + - "seeder no longer exercises the FlockScopeGuard narrowing this fixture depends on " + - "(Manager and Owner bypass it by role). Restore that user to Worker, or reset the fixture."); - } - else if (!roles.Contains(role)) - throw new InvalidOperationException( - $"Simulation seed: cast user {email} exists but does not hold the {role} role " + - $"(it holds: {(roles.Count == 0 ? "no roles" : string.Join(", ", roles))}). The cast was " + - "reconfigured outside the seeder; fix that user's role, or reset the fixture."); - - return new SimActor(existing.Id, existing.Email!, roles); - } - - // --- Minimal flock topology ---------------------------------------- - - // #279 review Fix 1 (codex): both flocks must be placed strictly OLDER - // than the deepest production-history entry SeedProductionHistoryAsync - // writes for EVERY flock (today.AddDays(-EffectiveHistoryDays(sim))) — - // previously House B was placed only ~70 days ago while - // Simulation:HistoryDays defaults to 90, so House B ended up with ~20 - // days of daily entries dated BEFORE it was placed. The margin below adds - // headroom on top of the history floor so placement is never merely - // equal to the oldest entry date. - private const int FlockPlacementMarginDays = 7; - - // #943 — shared with the production history, whose eggs are a lay rate - // over these birds less the removals the seeder itself writes. - private static readonly int[] OperationalFlockInitialCounts = [400, 350]; - - private async Task> SeedFlockTopologyAsync( - Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) - { - // Both flocks share the identical "safely older than all history" - // placement date — SeedFlockHistoryAsync's entry-date range - // (today.AddDays(-d) for d = 1..EffectiveHistoryDays(sim)) is the - // SAME for every flock, so both placements need the identical floor. - var placementDate = today.AddDays(-FlockPlacementLookbackDays(sim)); - - (string Name, string Breed, DateOnly PlacementDate, int InitialCount)[] wanted = - [ - ("Sim House A", "ISA Brown", placementDate, OperationalFlockInitialCounts[0]), - ("Sim House B", "Lohmann Brown", placementDate, OperationalFlockInitialCounts[1]), - ]; - - // A manager places the flocks (#500) — Flock.Create is audited, so this - // is what the SPA's History line names. Rotates per flock so a wider - // Managers pool spreads across the topology. - var ids = new List(); - for (var i = 0; i < wanted.Length; i++) - { - ActAs(Pick(cast.Managers, i, cast.Owner, "Managers")); - var f = wanted[i]; - ids.Add(await EnsureFlockAsync(accountId, f.Name, f.Breed, f.PlacementDate, f.InitialCount, ct)); - } - return ids; - } - - private async Task EnsureFlockAsync( - Guid accountId, string name, string breed, DateOnly placementDate, int initialCount, - CancellationToken ct) - { - // Tenant is resolved, so the query filter already scopes this to - // accountId. Duplicate names are legal, so a second flock with this - // name is drift the seed refuses to guess past (#852). - switch (await flocks.ResolveByNameAsync(name, ct)) - { - case FlockNameResolution.Found found: - return found.Flock.Id; - case FlockNameResolution.Ambiguous ambiguous: - throw new InvalidOperationException( - $"Simulation seed: {(ambiguous.MoreExist ? "more than " : "")}{ambiguous.Candidates.Count} flocks are named {name} " + - $"({string.Join(", ", ambiguous.Candidates.Select(c => c.Id))}). " + - "Rename the duplicates or reset the fixture."); - } - - var result = await flockModule.CreateAsync( - new CreateFlockCommand(name, breed, placementDate, initialCount), accountId, ct); - Require(result, $"create flock {name}"); - return result.Value; - } - - // #627 — the explicit bird-movement offsets run through 63, so the flock - // PLACEMENT (not the daily/feed/water/expense loop bounds — those keep - // EffectiveHistoryDays) needs at least 64 days of lookback: every explicit - // row must sit on/after its flock's placement date even in the shallowest - // fixture. For the 90-day default this is 97, exactly the pre-#627 value. - private static int FlockPlacementLookbackDays(SimulationOptions sim) => - Math.Max(EffectiveHistoryDays(sim) + FlockPlacementMarginDays, 64); - - // #627 — the independent picker catalog. Exactly 97 numbered Active rows - // ("Sim Z Flock Catalog 001".."097"), the Active page-two sentinel - // ("Sim Z Flock Page Two"), one row transitioned through - // DepleteFlockHandler ("Sim Z Flock Depleted"), and one through - // ArchiveFlockHandler ("Sim Z Flock Archived") — 100 Active / 1 Depleted - // / 1 Archived on top of the two operational houses, 102 flocks total - // (INV-5). None of these ids are ever returned to the caller, so no - // history fanout can reach them. - // #627 driver correction — every catalog row sorts AFTER the two - // operational houses: "Sim Z …" > "Sim House …". That is NOT the same as - // keeping all 100 catalog rows off page one of a 100-row default-limit - // picker — with the 101 non-archived seed rows, page one holds both houses - // plus the 98 earliest-sorting catalog rows, and only the lexically-last - // sentinel ("Sim Z Flock Page Two") falls onto page two. The guarantee is - // that the houses are INSIDE page one, not that the catalog is outside it. - private const string FlockCatalogNamePrefix = "Sim Z Flock Catalog "; - private const string FlockPageTwoSentinelName = "Sim Z Flock Page Two"; - private const string FlockDepletedName = "Sim Z Flock Depleted"; - private const string FlockArchivedName = "Sim Z Flock Archived"; - - private async Task SeedFlockCatalogAsync( - Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) - { - var placementDate = today.AddDays(-FlockPlacementLookbackDays(sim)); - var storeKeeper = Pick(cast.Managers, 1, cast.Owner, "Managers"); - // Local actor state, not the previous phase's: Flock.Create is audited - // (#500), so every one of the 100 catalog creations must attribute to - // the store-keeper by THIS phase's own resolution, reorder-safe. - ActAs(storeKeeper); - - for (var i = 1; i <= NumberedCatalogFlockCount; i++) // 97 numbered Active rows - await EnsureFlockAsync( - accountId, - $"{FlockCatalogNamePrefix}{i:000}", - "ISA Brown", - placementDate, - 100, - ct); - - await EnsureFlockAsync( - accountId, FlockPageTwoSentinelName, "ISA Brown", placementDate, 100, ct); - - // Lifecycle rows: create, then transition through the real handlers — - // the same domain rules the SPA enforces (deplete only from Active, - // archive from Active or Depleted). - var depletedId = await EnsureFlockAsync( - accountId, FlockDepletedName, "Lohmann Brown", placementDate, 100, ct); - await TransitionFlockAsync( - depletedId, FlockDepletedName, FlockStatus.Depleted, storeKeeper, - flockId => flockModule.DepleteAsync(flockId, ct), ct); - - var archivedId = await EnsureFlockAsync( - accountId, FlockArchivedName, "Lohmann Brown", placementDate, 100, ct); - await TransitionFlockAsync( - archivedId, FlockArchivedName, FlockStatus.Archived, storeKeeper, - flockId => flockModule.ArchiveAsync(flockId, ct), ct); - } - - // Rerun-safe lifecycle transition: a row already in the wanted status - // converges; a row in the WRONG status is drift the seed refuses to paper - // over (failing closed here rather than applying a second transition or - // silently accepting what the manifest's exact split would later reject). - private async Task TransitionFlockAsync( - Guid flockId, string name, FlockStatus wantedStatus, SimActor actor, - Func> transition, CancellationToken ct) - { - var flock = await flocks.GetAsync(flockId, ct) - ?? throw new InvalidOperationException( - $"Simulation seed: flock {name} was ensured but cannot be read back."); - if (flock.Status == wantedStatus) return; - - ActAs(actor); - var result = await transition(flockId); - if (result.IsFailure) - throw new InvalidOperationException( - $"Simulation seed: flock {name} exists in status {flock.Status} but the " + - $"transition to {wantedStatus} failed ({result.Error.Code}: {result.Error.Description}). " + - "A lifecycle row drifted outside the seeder — fix its status or reset the fixture."); - Require(result, $"transition flock {name} to {wantedStatus}"); - } - - // #627 — the explicit bird-adjustment band: 51 date-distinct rows on the - // first operational flock (Sim House A), through RecordBirdMovementHandler - // so every row carries the audit actor and the ledger's real semantics. - // - // The natural key is (FlockId, Date) — never the free-text note. The 51 - // offsets are the FIRST 51 positive day offsets not divisible by five, so - // no explicit row can land on a day SeedFlockHistoryAsync generates an - // automatic mortality row for (d % 5 == 0), and the last offset (63) is - // inside FlockPlacementLookbackDays' 64-day floor. The final row carries a - // stable sentinel note so the SPA ledger spec can prove the second page - // renders — but the note is DATA, not identity. - private const int ExplicitBirdMovementCount = 51; - // The 51st non-multiple of five (51 rows + 12 skipped offsets); the bound - // `LiveBirdsOnDay` walks the same band by. - private const int ExplicitBirdMovementLastOffset = 63; - private const string BirdMovementPageTwoSentinelNote = "Sim bird movement page two sentinel"; - - private async Task SeedExplicitBirdMovementsAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, - CancellationToken ct) - { - if (flockIds.Count == 0) - throw new InvalidOperationException( - "Simulation seed: no operational flocks exist to carry the explicit bird movements."); - - var flockId = flockIds[0]; - var storeKeeper = Pick(cast.Managers, 2, cast.Owner, "Managers"); - ActAs(storeKeeper); - - var offset = 0; - for (var d = 1; d <= FlockPlacementLookbackDays(sim) && offset < ExplicitBirdMovementCount; d++) - { - if (d % 5 == 0) continue; // automatic-mortality days stay unowned by this band - var date = today.AddDays(-d); - var isLast = offset == ExplicitBirdMovementCount - 1; - var note = isLast ? BirdMovementPageTwoSentinelNote : $"Simulation fixture explicit adjustment {d}"; - - var exists = await flockFixture.BirdMovementExistsAsync(flockId, date, ct); - if (!exists) - { - var result = await flockModule.RecordMovementAsync(new RecordBirdMovementCommand( - flockId, date, "Adjustment", 1, note), accountId, ct); - Require(result, $"record explicit bird movement for flock {flockId} on {date:yyyy-MM-dd}"); - } - offset++; - } - - // Fail closed rather than silently publish a short band: the probe - // above only skips rows that already exist, so a count short of 51 - // means the dates stopped being distinct. - var count = await flockFixture.CountAdjustmentsAsync(flockId, ct); - if (count < ExplicitBirdMovementCount) - throw new InvalidOperationException( - $"Simulation seed: only {count} explicit bird adjustments exist on the operational " + - $"flock; the fixture expects exactly {ExplicitBirdMovementCount} date-distinct rows."); - } - - // --- Restrict exactly one worker to one (not all) flocks ----------- - - // Returns the pair it restricted, so the caller can rebuild the cast record. - // - // BOTH the early returns matter, and the idempotent one is the trap (#500): - // it must report the EXISTING pair, never (Empty, Empty). Reporting Empty - // leaves RestrictedFlockId empty on every run after the first, so - // WorkerFor's `flockId == cast.RestrictedFlockId` never matches, the - // restricted worker becomes eligible for every flock, and the seed FAILS via - // FlockScopeGuard. A fully converged re-run hides it — the natural-key check - // short-circuits before WorkerFor is reached — so it surfaces only on a - // partial re-run that adds days. - private async Task<(Guid WorkerId, Guid FlockId)> RestrictOneWorkerAsync( - Guid accountId, SimCast cast, IReadOnlyList flockIds, CancellationToken ct) - { - // Granting a flock assignment is account administration — the Owner does - // it (#500). User.FlockAssign is audited. This phase runs directly after - // another Owner-authored one (SeedPrimaryTimeZoneAsync), so this ActAs is - // currently a no-op under today's ordering — kept for the same reason as - // that phase's own comment: order-independence, not decoration. - ActAs(cast.Owner); - - if (cast.Workers.Count == 0) - { - logger.LogWarning( - "Simulation:Workers is 0 — no worker exists to flock-restrict for the load test."); - return (Guid.Empty, Guid.Empty); - } - - if (flockIds.Count < 2) - throw new InvalidOperationException( - "Simulation seed needs at least 2 flocks so the restricted worker is genuinely " + - "narrowed (one assigned, one left out)."); - - var worker = cast.Workers[0]; - var flockId = flockIds[0]; - - // #606 — the interactive AssignFlockHandler now requires an - // interactive step-up grant this trusted, non-HTTP caller cannot - // hold, so the Access fixture writes the assignment with the SAME - // audit shape (actor, target email, flock NAME) the handler would - // have written — pinned by - // SimulationSeed_RestrictedWorkerAssignment_PreservesActorAndAuditDetails. - // An existing assignment is the idempotent re-run: the pair, not - // Empty. See the header. - await accessFixture.EnsureFlockAssignmentAsync(accountId, worker.UserId, worker.Email, flockId, ct); - return (worker.UserId, flockId); - } - - // --- Primary account timezone (BEFORE any dated data exists) ------- - - private async Task SeedPrimaryTimeZoneAsync(SimCast cast, SimulationOptions sim, CancellationToken ct) - { - // Farm settings are the Owner's to change (#500) — Account.UpdateSettings - // is audited. This ActAs is load-bearing, not decorative: the phase now - // runs directly after a MANAGER-authored one, so dropping it misattributes - // the row instead of doing nothing. - ActAs(cast.Owner); - - var account = await farm.GetSettingsAsync(ct) - ?? throw new InvalidOperationException("Simulation seed: the primary account was not found."); - - if (string.Equals(account.TimeZoneId, sim.TimeZoneId, StringComparison.Ordinal)) - return; // already set — idempotent re-run. - - var command = new UpdateFarmSettingsCommand( - account.Name, - sim.TimeZoneId, - account.Locale, - account.CurrencyCode, - account.UnitSystem.ToString(), - account.FirstDayOfWeek?.ToString(), - account.DateFormatOverride, - account.TimeFormatOverride, - account.Brand, - account.DefaultStepperUnit.ToString(), - account.WorkerSaleAllocationPolicy.ToString(), - account.Version, - // The command replaces the WHOLE settings block, so this phase must - // carry the farm's current ceiling forward or it would silently - // clear one (#727). The fixture sets none today; that is not a - // reason to hard-code null here. - account.MaxDiscountPercent); - - var result = await farm.UpdateSettingsAsync(command, ct); - Require(result, $"set primary account timezone to {sim.TimeZoneId}"); - } - - // --- Production daily-entry history (#243 later task) -------------- - - // One entry per flock per day across Simulation:HistoryDays (or - // MinSentinelAgeDays, whichever is longer — the lock-sweep proof needs - // the sentinel regardless of how short HistoryDays is configured for a - // test). Every date is at least 1 day older than the "today" anchor: - // RecordDailyEntry/CreateFlock validate against the farm's OWN today - // (IFarmClock, #35), which can already read one calendar day behind this - // UTC anchor for a timezone west of UTC depending on time-of-day skew — - // an entry dated exactly "today" could spuriously fail the future-date - // rule. Starting at day 1 keeps every seeded date safely <= the farm's - // today no matter when seeding happens to run. - private async Task SeedProductionHistoryAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, - CancellationToken ct) - { - // No ActAs here: SeedFlockHistoryAsync picks a worker per (flock, day) - // via WorkerFor. LoadSaleableGradesAsync below is a read and audits - // nothing. - var grades = await LoadSaleableGradesAsync(ct); - var historyDays = EffectiveHistoryDays(sim); - - for (var i = 0; i < flockIds.Count; i++) - await SeedFlockHistoryAsync( - accountId, flockIds[i], OperationalFlockInitialCounts[i], carriesExplicitAdjustments: i == 0, - today, historyDays, grades, cast, ct); - } - - // #943 — the birds a flock holds at the START of the day `d` days before - // today, mirroring the two removal schedules this seeder writes: one - // automatic mortality on every submitted day whose offset is a multiple - // of five (`SeedFlockHistoryAsync`), and, on the first house only, the - // 51 explicit +1 adjustments of `SeedExplicitBirdMovementsAsync` on the - // offsets 1..63 not divisible by five. Same start-of-day convention as - // the production report: only movements dated strictly before the day - // count. Computed rather than read back because the history loop walks - // newest-first, so the older days' mortality rows do not exist yet when - // a newer day's eggs are decided. - private static int LiveBirdsOnDay(int initialCount, int d, int historyDays, bool carriesExplicitAdjustments) - { - var removed = 0; - for (var earlier = d + 1; earlier <= historyDays; earlier++) - if (earlier > DraftWindowDays && earlier % 5 == 0) removed++; - if (carriesExplicitAdjustments) - for (var earlier = d + 1; earlier <= ExplicitBirdMovementLastOffset; earlier++) - if (earlier % 5 != 0) removed++; - return initialCount - removed; - } - - // Live birds at an 86–94% hen-day rate that varies day to day. A hen lays - // at most one egg a day; the former constant baseline (380 eggs from 350 - // birds) rated every day of the Dashboard and Reports over 100% (#943). - // Deterministic variation — no Random, reproducible seeds. - private static int EggsOnDay(int liveBirds, int d) => liveBirds * (86 + (d * 7) % 9) / 100; - - // Shared by production history (above) and the sales catalog (#243 Task - // 3b) — both need the same saleable-grade lookup, keyed by name. - private async Task> LoadSaleableGradesAsync(CancellationToken ct) - { - var grades = (await eggs.ListActiveGradesAsync(SeedDefaults.FarmId, ct)) - .Where(g => g.IsSaleable) - .ToDictionary(g => g.Name, g => g.Id); - if (grades.Count == 0) - throw new InvalidOperationException("Simulation seed needs the default egg grades."); - return grades; - } - - private async Task SeedFlockHistoryAsync( - Guid accountId, Guid flockId, int initialCount, bool carriesExplicitAdjustments, - DateOnly today, int historyDays, - IReadOnlyDictionary grades, SimCast cast, CancellationToken ct) - { - for (var d = 1; d <= historyDays; d++) - { - var date = today.AddDays(-d); - - // Idempotent re-run: RecordDailyEntryHandler itself only accepts - // edits to Draft entries, so a plain re-call would throw once the - // sentinel is Submitted/Locked — skip on existence instead - // (mirrors EnsureUserAsync/EnsureFlockAsync above). - if (await eggsFixture.DefaultHouseEntryExistsAsync(accountId, flockId, date, ct)) - continue; - - var total = EggsOnDay(LiveBirdsOnDay(initialCount, d, historyDays, carriesExplicitAdjustments), d); - var cracked = 4 + d % 3; - var dirty = 2 + d % 2; - const int discarded = 1; - var mortality = d % 5 == 0 ? 1 : 0; - var sellable = total - cracked - dirty - discarded; - var large = sellable * 55 / 100; - var medium = sellable * 30 / 100; - var small = sellable - large - medium; - - // #500 — a worker records the day's eggs. Placed AFTER the - // natural-key skip above so a converged re-run neither picks nor - // resolves anyone. - ActAs(WorkerFor(cast, flockId, d)); - - var recorded = await eggs.RecordDailyEntryAsync(new RecordDailyEntryCommand( - SeedDefaults.FarmId, SeedDefaults.HouseId, flockId, date, - total, cracked, dirty, discarded, mortality, - [ - new GradeQuantityDto(grades["Large"], large), - new GradeQuantityDto(grades["Medium"], medium), - new GradeQuantityDto(grades["Small"], small), - ]), accountId, ct); - Require(recorded, $"record daily entry for flock {flockId} on {date:yyyy-MM-dd}"); - var entryId = recorded.Value; - - // The most recent DraftWindowDays days stay Draft; everything - // older (including the day-9 sentinel) is submitted. - if (d > DraftWindowDays) - { - // #500 — both provenance shapes. Every SubmittedByManagerEvery-th - // day a manager signs the entry off, so its History line reads - // "created by X, last changed by Y"; otherwise the recording - // worker stays resolved and submits their own, which reads - // "created and last changed by the same person". - if (d % SubmittedByManagerEvery == 0) - ActAs(Pick(cast.Managers, d, cast.Owner, "Managers")); - - var submitted = await eggs.SubmitDailyEntryAsync(entryId, accountId, ct); - Require(submitted, $"submit daily entry {entryId} for flock {flockId} on {date:yyyy-MM-dd}"); - } - - // Every handler has saved; later days reload by id, so release the - // day's entities instead of rescanning the whole history (#1045). - db.ChangeTracker.Clear(); - } - } - - // --- Sales: catalog, customers, orders across the lifecycle, FIFO - // depletion, payments (#243 Task 3b) ------------------------------------ - // - // Everything below runs through the SAME real handlers DemoDataSeeder - // uses (CreateProduct/CreateCustomer/CreateSalesOrder/AddOrderItem/ - // ConfirmSale/RecordPayment) — orders confirmed here allocate FIFO - // against the graded lots the production history above just generated, - // exactly as a user clicking through the Sales screen would. Idempotency - // is per-entity existence checks (product/customer by Name, order by the - // (CustomerId, OrderDate) natural key this seeder controls), same style - // as EnsureUserAsync/EnsureFlockAsync above. - - // One product per saleable grade, sold in individual eggs (factor 1) — - // keeps order-item quantities directly comparable to lot sizes, which is - // what makes the FIFO-contention shape below easy to reason about. - // - // #396: Cracked/Dirty are here for a different reason than the graded - // three. Submit now mints a lot for each condition counter, so without a - // product mapped to those grades the fixture accumulates stock that can - // never leave — ~180 lots of dead inventory the sales screens can see and - // nothing can sell. Priced below Small, which is the point of the feature - // (a cracked egg is saleable, at a discount). Nothing here orders them; - // the FIFO-contention shape above stays a Large/Medium story. - private static readonly (string GradeName, string ProductName, long PriceMinorUnits)[] CatalogWanted = - [ - ("Large", "Sim Large Eggs", 45), - ("Medium", "Sim Medium Eggs", 38), - ("Small", "Sim Small Eggs", 30), - ("Cracked", "Sim Cracked Eggs", 18), - ("Dirty", "Sim Dirty Eggs", 22), - ]; - - // #720 — no default price at all, distinct from a below/above-list line: - // this is the "no comparable list price" state neither of those shows. - // Kept out of CatalogWanted/products (that dictionary is keyed by GRADE - // NAME): mapping it to "Large" like Sim Large Eggs would silently - // overwrite that key. No uniqueness constraint ties a grade to one - // product (ProductEggGradeMappingConfiguration only unique-indexes - // ProductId), so sharing the grade is fine. - private const string UnpricedProductName = "Sim Unpriced Eggs"; - - private static readonly (string Name, string Phone, string Note)[] CustomersWanted = - [ - ("Sim Customer 1", "555-0201", "Simulation fixture customer"), - ("Sim Customer 2", "555-0202", "Simulation fixture customer"), - ("Sim Customer 3", "555-0203", "Simulation fixture customer"), - ]; - - // #627 — over-cap customer band: the three lifecycle rows above stay the - // sales-order customers; 97 zero-padded fillers plus the lexically-last - // page-two sentinel bring the account total to exactly 101, so the - // customer list's 100-row page cannot reach the sentinel without Load more. - private const int CustomerFillerCount = 97; - private const string CustomerPageTwoSentinelName = "Sim Customer Z Page Two"; - - // Modest relative to a single day's Large lot (baseline 320+ eggs/day, - // ~55% Large) — small enough that every confirmed order below draws from - // the SAME shallow pool of the oldest available Large lots (FIFO always - // starts at the oldest ProductionDate) instead of each landing on - // disjoint fresh stock. That shared-pool shape is what a later hazard - // task needs to force FOR UPDATE lock contention; this task only sets - // the baseline shape up and never forces the contention itself. - private const int ConfirmedOrderQuantityEggs = 36; - private const int DraftOrderQuantityEggs = 24; - - // #243 Task 3d — see the class header's depth/density decision. This - // second series never touches days 1-7 (every hardcoded date above), - // starts at MinSentinelAgeDays (9, so it survives even a HistoryDays - // shorter than that floor), and repeats every RecurringCadenceDays out to - // the effective history length — spreading confirmed orders across the - // WHOLE window instead of leaving them clustered in the most recent week. - private const int RecurringStartDay = MinSentinelAgeDays; - private const int RecurringCadenceDays = 7; - private const int RecurringOrderQuantityEggs = 12; - - private async Task SeedSalesAsync( - Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) - { - var grades = await LoadSaleableGradesAsync(ct); - // The catalog is a manager's to define; the orders against it are the - // sales desk's (#500). Product.Create and SalesOrder.* are all audited. - var products = await SeedProductCatalogAsync( - accountId, grades, Pick(cast.Managers, 0, cast.Owner, "Managers"), ct); - var unpricedProductId = await EnsureProductAsync( - accountId, UnpricedProductName, grades["Large"], null, - Pick(cast.Managers, 0, cast.Owner, "Managers"), ct); - var customerIds = await SeedCustomersAsync(accountId, cast, ct); - - var largeProductId = products["Large"]; - var mediumProductId = products["Medium"]; - var smallProductId = products["Small"]; - var customer1 = customerIds[0]; - var customer2 = customerIds[1]; - var customer3 = customerIds[2]; - - // Each order carries its own index into the Sales pool: these five call - // sites have no loop of their own, so the CALLER owns the rotation - // rather than a helper inventing one. - SimActor Clerk(int index) => Pick(cast.Sales, index, cast.Owner, "Sales"); - - // Draft: created, items added, never confirmed — no stock touched. - var draft1Id = await EnsureDraftOrderAsync( - accountId, customer1, today.AddDays(-4), mediumProductId, DraftOrderQuantityEggs, Clerk(0), ct); - var draft2Id = await EnsureDraftOrderAsync( - accountId, customer2, today.AddDays(-3), mediumProductId, DraftOrderQuantityEggs, Clerk(1), ct); - - // #720 — every line seeded above sells at the product's own default - // (both add-item call sites pass null/null), so three of the four - // render states (below list, above list, no list price) were never - // seeded. These land on the two EXISTING drafts, not new orders, so - // SimulationDataSeeder's own order/line count manifest (which tracks - // orders, never lines or products) stays untouched. - // - // #721 — the BELOW-list line must stay on a draft. Confirming an order - // that carries one now requires a discount reason, and the seeders build - // ConfirmSaleCommand directly with none, so moving this onto an order - // EnsureConfirmedOrderAsync touches would fail the seed at runtime in a - // verb nothing in CI runs. - await EnsureExtraLineAsync( - accountId, draft1Id, largeProductId, DraftOrderQuantityEggs, 35, Clerk(0), ct); // list 45 → below - await EnsureExtraLineAsync( - accountId, draft2Id, smallProductId, DraftOrderQuantityEggs, 40, Clerk(1), ct); // list 30 → above - await EnsureExtraLineAsync( - accountId, draft2Id, unpricedProductId, DraftOrderQuantityEggs, 25, Clerk(1), ct); // no list price - - // Confirmed (unpaid): same product/grade as every other confirmed - // order below — see ConfirmedOrderQuantityEggs for why that matters. - await EnsureConfirmedOrderAsync( - accountId, customer1, today.AddDays(-6), largeProductId, ConfirmedOrderQuantityEggs, Clerk(2), ct); - await EnsureConfirmedOrderAsync( - accountId, customer2, today.AddDays(-5), largeProductId, ConfirmedOrderQuantityEggs, Clerk(3), ct); - - // Confirmed + partially paid — also left un-voided, so it doubles as - // the "voidable confirmed order" a later hazard pass can void. - var partialOrderId = await EnsureConfirmedOrderAsync( - accountId, customer3, today.AddDays(-2), largeProductId, ConfirmedOrderQuantityEggs, Clerk(4), ct); - await EnsurePartialPaymentAsync(accountId, partialOrderId, today.AddDays(-1), Clerk(4), ct); - - await SeedRecurringOrdersAsync(accountId, today, customerIds, largeProductId, cast, sim, ct); - } - - // Spreads a modest, deterministic drip of additional confirmed orders - // across the full history window — see RecurringStartDay/RecurringCadenceDays - // above. Always the Large product: keeps it inside the same FIFO - // shared-old-lot-pool shape ConfirmedOrderQuantityEggs already documents, - // and every date here is <= EffectiveHistoryDays, so production history - // (seeded before sales) always has a lot on or before that date to - // allocate from. - private async Task SeedRecurringOrdersAsync( - Guid accountId, DateOnly today, IReadOnlyList customerIds, Guid largeProductId, - SimCast cast, SimulationOptions sim, CancellationToken ct) - { - var historyDays = EffectiveHistoryDays(sim); - var i = 0; - for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays, i++) - { - var customerId = customerIds[i % customerIds.Count]; - await EnsureConfirmedOrderAsync( - accountId, customerId, today.AddDays(-d), largeProductId, RecurringOrderQuantityEggs, - Pick(cast.Sales, i, cast.Owner, "Sales"), ct); - } - } - - private async Task> SeedProductCatalogAsync( - Guid accountId, IReadOnlyDictionary grades, SimActor actor, CancellationToken ct) - { - var products = new Dictionary(); - foreach (var (gradeName, productName, price) in CatalogWanted) - { - if (!grades.TryGetValue(gradeName, out var gradeId)) - throw new InvalidOperationException( - $"Simulation seed needs the '{gradeName}' egg grade for the sales catalog."); - products[gradeName] = await EnsureProductAsync(accountId, productName, gradeId, price, actor, ct); - } - return products; - } - - private async Task EnsureProductAsync( - Guid accountId, string name, Guid eggGradeId, long? priceMinorUnits, SimActor actor, CancellationToken ct) - { - if (await commerceFixture.FindProductIdByNameAsync(name, ct) is Guid existingId) return existingId; - - ActAs(actor); - var result = await commerce.CreateProductAsync(new CreateProductCommand( - name, "Egg", "Egg", priceMinorUnits, eggGradeId, "Simulation fixture product"), accountId, ct); - Require(result, $"create product {name}"); - return result.Value; - } - - // Customer.Create is audited as the Sales actor declared per customer here, - // for consistency with the orders these customers receive. - private async Task> SeedCustomersAsync( - Guid accountId, SimCast cast, CancellationToken ct) - { - var ids = new List(); - for (var i = 0; i < CustomersWanted.Length; i++) - { - var (name, phone, note) = CustomersWanted[i]; - ids.Add(await EnsureCustomerAsync( - accountId, name, phone, note, Pick(cast.Sales, i, cast.Owner, "Sales"), ct)); - } - - // #627 — the over-cap fillers and the lexically-last page-two sentinel. - // The three lifecycle customers above are the only ones orders touch; - // these rows are list volume, created through the same handler with - // the same name-probe idempotency. - for (var i = 1; i <= CustomerFillerCount; i++) - await EnsureCustomerAsync( - accountId, - $"Sim Customer Filler {i:000}", - $"555-03{i:00}", - "Simulation fixture customer", - Pick(cast.Sales, i, cast.Owner, "Sales"), - ct); - - await EnsureCustomerAsync( - accountId, - CustomerPageTwoSentinelName, - "555-0299", - "Simulation fixture customer", - Pick(cast.Sales, CustomerFillerCount + 1, cast.Owner, "Sales"), - ct); - - return ids; - } - - private async Task EnsureCustomerAsync( - Guid accountId, string name, string phone, string note, SimActor actor, CancellationToken ct) - { - if (await commerceFixture.FindCustomerIdByNameAsync(name, ct) is Guid existingId) return existingId; - - ActAs(actor); - var result = await commerce.CreateCustomerAsync( - new CreateCustomerCommand(name, phone, Note: note), accountId, ct); - Require(result, $"create customer {name}"); - return result.Value; - } - - // (CustomerId, OrderDate) is the natural key THIS seeder controls (every - // call site below uses a distinct date per customer) — SalesOrder itself - // has no other stable, human-chosen identity to check idempotency against - // (ReferenceNumber is minted from a random order id inside the handler). - private Task FindOrderAsync(Guid customerId, DateOnly orderDate, CancellationToken ct) => - commerceFixture.FindOrderIdAsync(customerId, orderDate, ct); - - private async Task EnsureDraftOrderAsync( - Guid accountId, Guid customerId, DateOnly orderDate, Guid productId, int quantityEggs, - SimActor actor, CancellationToken ct) - { - var existing = await FindOrderAsync(customerId, orderDate, ct); - if (existing is not null) return existing.Value; - - ActAs(actor); - var created = await commerce.CreateSalesOrderAsync( - new CreateSalesOrderCommand(customerId, orderDate), accountId, ct); - Require(created, $"create sales order for customer {customerId} on {orderDate:yyyy-MM-dd}"); - var orderId = created.Value; - - // Unit/price both null: defaults from the product (Egg unit, the - // catalog price seeded above) — same pattern as DemoDataSeeder. - var added = await commerce.AddOrderItemAsync( - new AddOrderItemCommand(orderId, productId, quantityEggs, null, null), accountId, ct); - Require(added, $"add item to sales order {orderId}"); - - return orderId; - } - - // #720 — a SECOND line on an already-seeded draft, at an EXPLICIT price so - // the fixture can show a discounted, an above-list, and an unpriced-product - // line (every seeded line was at-list before this, since both add-item call - // sites passed null/null). Idempotent on (SalesOrderId, ProductId): this - // order's base line already occupies its own product, so a distinct - // product per extra line is what keeps a rerun from re-adding it. - private async Task EnsureExtraLineAsync( - Guid accountId, Guid orderId, Guid productId, int quantityEggs, long explicitPriceMinorUnits, - SimActor actor, CancellationToken ct) - { - if (await commerceFixture.OrderHasLineAsync(orderId, productId, ct)) return; - - ActAs(actor); - var added = await commerce.AddOrderItemAsync( - new AddOrderItemCommand(orderId, productId, quantityEggs, null, explicitPriceMinorUnits), - accountId, ct); - Require(added, $"add extra line to sales order {orderId}"); - } - - private async Task EnsureConfirmedOrderAsync( - Guid accountId, Guid customerId, DateOnly orderDate, Guid productId, int quantityEggs, - SimActor actor, CancellationToken ct) - { - var orderId = await EnsureDraftOrderAsync( - accountId, customerId, orderDate, productId, quantityEggs, actor, ct); - - // Status, not "the order already existed", decides whether to - // confirm — an order that exists but is still Draft still needs - // confirming on a re-run. - if (await commerceFixture.IsOrderConfirmedAsync(orderId, ct)) return orderId; - - // Re-declared rather than inherited from EnsureDraftOrderAsync: on the - // re-run path above, that call returns early without acting at all, so - // the resolved actor would be whoever the previous phase left behind — - // a manager, from the inventory phase — and the confirmation would be - // authorized and audited as them. - // - // NOT COVERED BY ANY TEST, stated rather than left to be assumed (#500 - // mid-point review; confirmed by mutation — deleting this line leaves - // the whole suite green). Reaching it needs a fixture where a DRAFT - // order survives into a re-run, and the only way to build one is to - // force a confirmed order back to Draft, which replays FIFO allocation - // against the same lots. The line is correct and cheap; the fixture - // that would prove it is neither. - ActAs(actor); - // FIFO allocation (tech spec §10.9.1): draws from the oldest - // available lots for this grade under a FOR UPDATE lock — this is - // what depletes the production history's egg lots. - var confirmed = await commerce.ConfirmSaleAsync(new ConfirmSaleCommand(orderId), accountId, actor.UserId, ct); - Require(confirmed, $"confirm sales order {orderId}"); - return orderId; - } - - // Payment.* is audited by nothing — same standing as customers above. - private async Task EnsurePartialPaymentAsync( - Guid accountId, Guid orderId, DateOnly paymentDate, SimActor actor, CancellationToken ct) - { - if (await commerceFixture.PaymentExistsAsync(orderId, ct)) return; - - // Half the total, rounded down — strictly less than TotalAmount so - // the order stays genuinely partially paid, never fully settled. - var amount = await commerceFixture.GetOrderTotalMinorUnitsAsync(orderId, ct) / 2; - if (amount <= 0) - throw new InvalidOperationException( - $"Simulation seed: sales order {orderId}'s total is too small to seed a partial payment."); - - ActAs(actor); - var result = await commerce.RecordPaymentAsync(new RecordPaymentCommand( - orderId, paymentDate, amount, "Cash", null, "Simulation fixture partial payment"), accountId, ct); - Require(result, $"record partial payment for sales order {orderId}"); - } - - // --- Inventory: items, opening purchases, adjustments, feed/water usage, - // expenses (#243 Task 3c) -------------------------------------------- - // - // Same real-handler-reuse discipline as sales above (CreateInventoryItem/ - // RecordPurchase/RecordAdjustment/RecordFeedUsage/RecordWaterUsage/ - // CreateExpenseCategory/CreateExpense) so reports/exports built against - // this data see the same shapes a user clicking through Inventory/ - // Expenses would produce. Idempotency: items/categories by Name - // (EnsureFlockAsync-style); the opening purchase by "does the item - // already have a lot" (HasLotsAsync — this seeder only ever creates ONE - // opening lot per item); the adjustment by "does the lot already carry a - // correction movement"; usage/expense rows by the (flock/item, date) or - // Description natural key this seeder controls, same convention as - // SeedFlockHistoryAsync's natural-key skip and FindOrderAsync above. - - private static readonly (string Name, string Category, string Unit, long DefaultUnitCostMinorUnits)[] - InventoryItemsWanted = - [ - ("Sim Layer Feed", "Feed", "kg", 42), - ("Sim Pine Shavings", "Bedding", "bags", 850), - ]; - - private const decimal FeedOpeningPurchaseQuantity = 3000m; // kg - private const decimal BeddingOpeningPurchaseQuantity = 120m; // bags - private const decimal FeedDiscardQuantity = -15m; // kg — spoilage write-off - private const decimal BeddingAdjustmentQuantity = -6m; // bags — recount shrinkage - - // Modest relative to the opening purchase (net of the discard above) — - // total feed usage across every flock/day stays far inside what's on - // hand, so the seed never fails closed on insufficient stock regardless - // of how many flocks are configured. - private const decimal FeedUsagePerFlockPerDay = 18m; // kg - private const int FeedUsageDays = 4; - - private const decimal WaterUsagePerFlockPerDay = 250m; // L - private const int WaterUsageDays = 4; - - private async Task SeedInventoryOperationsAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, - CancellationToken ct) - { - // #500 — stores and feeding are a manager's job. Of everything below only - // InventoryItem.Adjust and Expense.Create write an audit row, but each - // helper takes its actor explicitly anyway: the caller owning the choice - // is what keeps a later reorder from silently changing attribution. - // Manager also matters functionally here — feed/water usage go through - // FlockScopeGuard, which passes a Manager by role. - var storeKeeper = Pick(cast.Managers, 0, cast.Owner, "Managers"); - var (feedItemId, feedLotId, beddingItemId, beddingLotId) = - await SeedInventoryItemsAndOpeningPurchasesAsync(accountId, today, cast, sim, ct); - - await SeedFeedUsageAsync(accountId, today, flockIds, feedItemId, storeKeeper, ct); - - // The two original correction rows, kept on opening + 2 (see the - // opening-lookback note in SeedInventoryItemsAndOpeningPurchasesAsync). - // They run AFTER the feed usages for the same domain reason as the - // adjustment band below: a discard/correction is legal only once the - // lot has been drawn down below its received quantity. - var adjustmentDate = today.AddDays(-(InventoryOpeningLookbackDays(sim) - 2)); - await EnsureAdjustmentAsync( - accountId, feedItemId, feedLotId, adjustmentDate, "Discard", - FeedDiscardQuantity, "Simulation fixture: torn bag spoiled in storage", storeKeeper, ct); - await EnsureAdjustmentAsync( - accountId, beddingItemId, beddingLotId, adjustmentDate, "Adjustment", - BeddingAdjustmentQuantity, "Simulation fixture: recount shrinkage", storeKeeper, ct); - - // #627 — the over-cap band, after the usages too (same rule). - await SeedFeedAdjustmentsAsync(accountId, today, feedItemId, feedLotId, storeKeeper, sim, ct); - - await SeedWaterUsageAsync(accountId, today, flockIds, storeKeeper, ct); - await SeedExpensesAsync(accountId, today, flockIds, cast, sim, ct); - } - - // #627 — the over-cap inventory band: exactly 110 date-distinct +1 - // Adjustment rows on the feed lot's OPENING lot. The item ends with ten - // other movements (one purchase, one discard, eight usages), so the band - // reaches exactly 120 on the feed item while the bedding item keeps its - // two, making the account-wide total exactly 122. - // - // Seeded AFTER the feed usages (which draw the lot down below its received - // quantity), never before: InventoryLot.Adjust refuses a correction that - // would leave a lot at or above what was received. The movement COUNT is - // order-independent (the ledger is append-only), so the final band is the - // same either way — only the legal order of the writes differs. - // - // Natural key (InventoryLotId, Date), never the free-text reason. Dates - // enumerate forward from opening + 1, skipping the existing discard at - // opening + 2, and stop after 110 rows — all on/after the lot's receipt - // and at least six days before the anchor. The oldest added row carries a - // stable sentinel reason so the SPA ledger spec can prove its second - // page renders; the reason is DATA, not identity. - private const int FeedAdjustmentCount = 110; - private const string FeedAdjustmentPageTwoSentinelReason = "Sim feed adjustment page two sentinel"; - - // Mirrors the openingDate note in - // SeedInventoryItemsAndOpeningPurchasesAsync — the two floors (the +5 - // margin and the 117-day #627 floor) are one decision and live next to - // each other there. - private static int InventoryOpeningLookbackDays(SimulationOptions sim) => - Math.Max(EffectiveHistoryDays(sim) + 5, 117); - - private async Task SeedFeedAdjustmentsAsync( - Guid accountId, DateOnly today, Guid feedItemId, Guid feedLotId, SimActor actor, - SimulationOptions sim, CancellationToken ct) - { - ActAs(actor); - - var added = 0; - for (var d = 1; d <= InventoryOpeningLookbackDays(sim) - 1 && added < FeedAdjustmentCount; d++) - { - if (d == 2) continue; // the existing discard owns opening + 2 - var date = today.AddDays(-(InventoryOpeningLookbackDays(sim) - d)); - var isOldest = added == 0; // first date enumerated == oldest == the sentinel - var reason = isOldest - ? FeedAdjustmentPageTwoSentinelReason - : $"Simulation fixture feed adjustment {added + 1}"; - - var exists = await inventoryFixture.FeedLotMovementExistsAsync(feedLotId, date, ct); - if (!exists) - { - var result = await inventory.RecordAdjustmentAsync(new RecordAdjustmentCommand( - feedItemId, feedLotId, date, "Adjustment", 1m, reason), accountId, ct); - Require(result, $"record feed adjustment for lot {feedLotId} on {date:yyyy-MM-dd}"); - } - added++; - } - - // Fail closed rather than silently publish a short band (the probe - // above only skips rows that already exist, so shortness means the - // dates stopped being distinct). - var count = await inventoryFixture.CountFeedLotAdjustmentsAsync(feedLotId, ct); - if (count < FeedAdjustmentCount) - throw new InvalidOperationException( - $"Simulation seed: only {count} feed-lot adjustment rows exist; the fixture " + - $"expects exactly {FeedAdjustmentCount} date-distinct rows."); - } - - // #627 — extracted (behavior-identical) from SeedInventoryOperationsAsync - // so the phase owns the item + opening-lot ids its callers (the band, - // the correction rows, the usage loops) all need: the band's +1 rows and - // the original discard/correction rows are all LOT CHANGES, and - // InventoryLot.Adjust's domain rule (AdjustExceedsReceived) refuses any - // change that would leave the lot at or above its received quantity — - // so every one of them is legal only once the feed usages have drawn the - // lot down. SeedInventoryOperationsAsync therefore seeds the usages - // BEFORE those rows; the append-only ledger makes the movement COUNT - // order-independent, so the certified bands are the same either way. The - // opening purchase itself still precedes every usage, as before. - private async Task<(Guid FeedItemId, Guid FeedLotId, Guid BeddingItemId, Guid BeddingLotId)> - SeedInventoryItemsAndOpeningPurchasesAsync( - Guid accountId, DateOnly today, SimCast cast, SimulationOptions sim, CancellationToken ct) - { - var storeKeeper = Pick(cast.Managers, 0, cast.Owner, "Managers"); - - // Received well before any usage date so FIFO always finds it on-hand - // as-of any usage day — same "older than everything it must cover" - // shape as the flock placement dates in SeedFlockTopologyAsync. - // - // #627 — the feed lot's 110 deterministic adjustments need 116 distinct - // days of room on/after receipt (opening + 1 through opening + 116, - // minus the existing discard at opening + 2), so the opening lookback is - // floored at 117: the latest added date then lands at anchor day - 6, - // clear of the four recent feed-usage dates and any farm-today skew that - // trails the UTC anchor. #627 intentionally moves the 90-day default - // opening from 95 to 117 — the band needs the floor, not the history - // margin. - // - // The lot's received quantity (3000) is NOT sized for the band: the - // band's legality is ORDER, not headroom — the lot starts its life AT - // its received quantity, where InventoryLot.Adjust refuses even one +1 - // (AdjustExceedsReceived), so the 144 kg of usages and the -15 discard - // must run BEFORE the 110 +1 rows, which then land at 2951 < 3000. - var openingDate = today.AddDays(-InventoryOpeningLookbackDays(sim)); - - var itemIds = new Dictionary(); - foreach (var (name, category, unit, cost) in InventoryItemsWanted) - itemIds[name] = await EnsureInventoryItemAsync(accountId, name, category, unit, cost, storeKeeper, ct); - var feedItemId = itemIds["Sim Layer Feed"]; - var beddingItemId = itemIds["Sim Pine Shavings"]; - - var feedLotId = await EnsureOpeningPurchaseAsync( - accountId, feedItemId, openingDate, FeedOpeningPurchaseQuantity, storeKeeper, ct); - var beddingLotId = await EnsureOpeningPurchaseAsync( - accountId, beddingItemId, openingDate, BeddingOpeningPurchaseQuantity, storeKeeper, ct); - - return (feedItemId, feedLotId, beddingItemId, beddingLotId); - } - - private async Task EnsureInventoryItemAsync( - Guid accountId, string name, string category, string unit, long defaultUnitCostMinorUnits, - SimActor actor, CancellationToken ct) - { - if (await inventoryFixture.FindItemIdByNameAsync(name, ct) is Guid existingId) return existingId; - - ActAs(actor); - var result = await inventory.CreateItemAsync( - new CreateInventoryItemCommand(name, category, unit, defaultUnitCostMinorUnits), accountId, ct); - Require(result, $"create inventory item {name}"); - return result.Value; - } - - private async Task EnsureOpeningPurchaseAsync( - Guid accountId, Guid itemId, DateOnly receivedDate, decimal quantity, SimActor actor, - CancellationToken ct) - { - // The lot check doubles as the idempotency probe: this seeder only - // ever creates ONE opening lot per item, so "any lot exists" is - // equivalent to "the opening purchase already ran". - if (await inventoryFixture.FindLotIdAsync(itemId, ct) is Guid existingLotId) return existingLotId; - - ActAs(actor); - // UnitCostMinorUnits omitted: falls back to the item's default cost - // set above. - var result = await inventory.RecordPurchaseAsync(new RecordPurchaseCommand( - itemId, receivedDate, quantity, UnitCostMinorUnits: null, LotNumber: "SIM-OPEN-1", - ExpiryDate: null, Note: "Simulation fixture opening stock"), accountId, ct); - Require(result, $"record opening purchase for inventory item {itemId}"); - return result.Value; - } - - private async Task EnsureAdjustmentAsync( - Guid accountId, Guid itemId, Guid lotId, DateOnly date, string type, decimal quantityDelta, string reason, - SimActor actor, CancellationToken ct) - { - if (await inventoryFixture.HasAdjustmentOrDiscardAsync(lotId, ct)) return; - - ActAs(actor); - var result = await inventory.RecordAdjustmentAsync(new RecordAdjustmentCommand( - itemId, lotId, date, type, quantityDelta, reason), accountId, ct); - Require(result, $"record inventory adjustment for lot {lotId}"); - } - - private async Task SeedFeedUsageAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, Guid feedItemId, SimActor actor, - CancellationToken ct) - { - // RecordFeedUsageHandler goes through FlockScopeGuard — the actor must be - // one the guard lets past for BOTH flocks, which a Manager is by role. - ActAs(actor); - - foreach (var flockId in flockIds) - for (var d = 1; d <= FeedUsageDays; d++) - { - // Day 1+ past, matching the daily-entry-history convention - // above (day-of-anchor could spuriously read as "future" - // against the farm's own, timezone-skewed today). - var date = today.AddDays(-d); - if (await inventoryFixture.FeedUsageExistsAsync(flockId, feedItemId, date, ct)) continue; - - var result = await inventory.RecordFeedUsageAsync(new RecordFeedUsageCommand( - flockId, feedItemId, date, FeedUsagePerFlockPerDay, - "Simulation fixture daily feeding"), accountId, ct); - Require(result, $"record feed usage for flock {flockId} on {date:yyyy-MM-dd}"); - } - } - - private async Task SeedWaterUsageAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, SimActor actor, CancellationToken ct) - { - // Flock-scoped, same as feed usage above. - ActAs(actor); - - foreach (var flockId in flockIds) - for (var d = 1; d <= WaterUsageDays; d++) - { - var date = today.AddDays(-d); - if (await inventoryFixture.WaterUsageExistsAsync(flockId, date, ct)) continue; - - var result = await inventory.RecordWaterUsageAsync(new RecordWaterUsageCommand( - flockId, date, WaterUsagePerFlockPerDay, Unit: "L", Source: "Well", - MeterStart: null, MeterEnd: null, Note: "Simulation fixture daily water"), accountId, ct); - Require(result, $"record water usage for flock {flockId} on {date:yyyy-MM-dd}"); - } - } - - private static readonly string[] ExpenseCategoriesWanted = ["Sim Utilities", "Sim Repairs & Maintenance"]; - - // Deterministic recurring expense amount — see RecurringStartDay/ - // RecurringCadenceDays above for why this exists (same thin-tail problem - // as sales, same fix). - private const long RecurringExpenseAmountMinorUnits = 4_000; - - private async Task SeedExpensesAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, SimCast cast, SimulationOptions sim, - CancellationToken ct) - { - var categoryIds = await SeedExpenseCategoriesAsync(accountId, cast, ct); - - // flockIds[1] is safe here: RestrictOneWorkerAsync already throws if - // fewer than 2 flocks exist, and it runs before this step. - (string Category, int DaysAgo, string Description, long AmountMinorUnits, Guid? FlockId)[] wanted = - [ - ("Sim Utilities", 7, "Sim Electricity Bill", 15_000, null), - ("Sim Utilities", 3, "Sim Water Utility Bill", 8_000, null), - ("Sim Repairs & Maintenance", 5, "Sim Coop Roof Repair", 42_000, flockIds[0]), - ("Sim Repairs & Maintenance", 2, "Sim Feeder Replacement Part", 6_500, flockIds[1]), - ]; - - // Expense.Create is audited — a manager books the farm's spending (#500). - // Rotates per expense so a wider Managers pool spreads across them. - for (var i = 0; i < wanted.Length; i++) - { - var (category, daysAgo, description, amount, flockId) = wanted[i]; - await EnsureExpenseAsync( - accountId, categoryIds[category], today.AddDays(-daysAgo), description, amount, flockId, - Pick(cast.Managers, i, cast.Owner, "Managers"), ct); - } - - await SeedRecurringExpensesAsync(accountId, today, flockIds, categoryIds, cast, sim, ct); - } - - // Spreads a modest, deterministic drip of additional expenses across the - // full history window — mirrors SeedRecurringOrdersAsync. Category and - // flock attribution alternate so both categories and both flock-attributed - // / farm-wide expenses keep growing as HistoryDays grows, not just the - // count. - private async Task SeedRecurringExpensesAsync( - Guid accountId, DateOnly today, IReadOnlyList flockIds, - IReadOnlyDictionary categoryIds, SimCast cast, SimulationOptions sim, CancellationToken ct) - { - var historyDays = EffectiveHistoryDays(sim); - var i = 0; - for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays, i++) - { - var category = i % 2 == 0 ? "Sim Utilities" : "Sim Repairs & Maintenance"; - var flockId = i % 2 == 0 ? (Guid?)null : flockIds[i % flockIds.Count]; - var description = $"Sim Recurring Expense Day {d}"; - await EnsureExpenseAsync( - accountId, categoryIds[category], today.AddDays(-d), description, - RecurringExpenseAmountMinorUnits, flockId, - Pick(cast.Managers, i, cast.Owner, "Managers"), ct); - } - } - - private async Task> SeedExpenseCategoriesAsync( - Guid accountId, SimCast cast, CancellationToken ct) - { - var categoryIds = new Dictionary(); - for (var i = 0; i < ExpenseCategoriesWanted.Length; i++) - categoryIds[ExpenseCategoriesWanted[i]] = await EnsureExpenseCategoryAsync( - accountId, ExpenseCategoriesWanted[i], Pick(cast.Managers, i, cast.Owner, "Managers"), ct); - return categoryIds; - } - - private async Task EnsureExpenseCategoryAsync( - Guid accountId, string name, SimActor actor, CancellationToken ct) - { - if (await financeFixture.FindCategoryIdByNameAsync(name, ct) is Guid existingId) return existingId; - - ActAs(actor); - var result = await finance.CreateCategoryAsync(new CreateExpenseCategoryCommand(name), accountId, ct); - Require(result, $"create expense category {name}"); - return result.Value; - } - - private async Task EnsureExpenseAsync( - Guid accountId, Guid categoryId, DateOnly date, string description, long amountMinorUnits, Guid? flockId, - SimActor actor, CancellationToken ct) - { - // Description is the natural key THIS seeder controls (every entry - // above is a distinct fixture string) — same convention as - // FindOrderAsync's (CustomerId, OrderDate) above. - if (await financeFixture.ExpenseExistsAsync(description, ct)) return; - - ActAs(actor); - var result = await finance.CreateExpenseAsync(new CreateExpenseCommand( - categoryId, date, description, amountMinorUnits, flockId, - Note: "Simulation fixture expense"), accountId, ct); - Require(result, $"create expense {description}"); - } - - // --- Second, pristine account (tenant-isolation fixture) ----------- - - private async Task SeedSecondAccountAsync(CancellationToken ct) - { - // The account query filter would hide a second tenant's row — the - // fixture reads with it off, regardless of which tenant is resolved. - if (await farmFixture.AccountExistsAsync(SecondAccountId, ct)) return; - - // #546 — this row belongs to the SECOND account while this seeder's own - // scope is resolved to the PRIMARY one (see SeedAsync), so writing it - // through the seeder's own scope is a cross-tenant write and TenantStampInterceptor now - // refuses it. Give the write its own scope resolved to the account it - // actually writes, mirroring DailyEntryLockSweep.LockDueEntriesAsync. - // - // BOTH the TenantContext and the fixture must come from the new scope. - // The interceptor is bound to the scope that constructed its - // DbContext, so resolving the tenant here and still writing through the - // injected farmFixture would leave the guard reading the PRIMARY - // account — a no-op that looks like a fix. Resolving the injected - // `tenant` field instead would throw: it is single-assignment as of - // this same slice. - using var scope = scopeFactory.CreateScope(); - scope.ServiceProvider.GetRequiredService().Resolve(SecondAccountId); - await scope.ServiceProvider.GetRequiredService().CreateAccountAsync( - SecondAccountId, "Simulation Second Farm", "simulation-second-farm", "UTC", "USD", ct); - logger.LogInformation("Seeded second pristine simulation account {AccountId}.", SecondAccountId); - } - private static void Require(Result result, string what) { if (result.IsFailure) @@ -1889,644 +421,4 @@ private static void Require(Result result, string what) => private static void Require(Result result, string what) => Require(result.IsSuccess ? Result.Success() : Result.Failure(result.Error), what); - - // --- Completion manifest (#243 Task 3e) ----------------------------- - // - // The final seeding step: COUNT what was actually created, VALIDATE - // those counts against what the configured SimulationOptions intended to - // create, and (only when CredentialOutputPath is set) WRITE a JSON - // manifest recording the result. This is what closes the "partial seed - // silently looks done" gap — the #243 findings header and #277's - // Playwright suite both read this file and trust `complete: true` outright; - // a short/partial seed must throw HERE instead of quietly producing a - // manifest that says otherwise. (SeedAsync itself returns a SeedResult, not - // the manifest — #279; the manifest is the on-disk artifact, and tests - // point Simulation:CredentialOutputPath at a temp file to read it back.) - // - // Depth-robust by construction: every expectation in ComputeExpectedCounts - // is derived from SimulationOptions (sim.Managers/.../HistoryDays), the - // seeder's own "today"/farm-local-"today" anchors, or this class's own - // structural constants (RecurringStartDay/RecurringCadenceDays, - // DraftWindowDays, GradesPerDailyEntry, FeedUsageDays/WaterUsageDays, the - // hardcoded topology/catalog/expense array lengths) — nothing here - // hardcodes "90 days" or "12 days"; the shallow SimulationSeedFactory test - // fixture (HistoryDays=12) and the real 90-day default validate against - // the SAME formulas. - // - // #638 — that claim covers the EXPECTATIONS, and until #638 the SEEDING - // was not depth-robust to match: the lock sweep ran once, capped at - // DailyEntryLockSweep.BatchSize, so above HistoryDays 107 the Locked - // expectation below could not be met however clean the database. The - // seeding side is DrainLockSweepAsync's job; read the two together before - // widening either claim. - // - // #279 review Fix 2 (Agent A + pi + codex, consensus): every count below - // is asserted EXACTLY, not as a ">= 1" floor — including the - // Draft/Submitted/Locked lifecycle split (Locked is derived by mirroring - // DailyEntryLockSweep's own cutoff — see ExpectedLockedEntryCount below — - // rather than left unchecked; Draft/Submitted then reconcile against the - // total). A bug that drops all-but-one row of any seeder-controlled band - // now fails ValidateCounts instead of still certifying complete: true. - - // Bumped to 2 by #627: the persisted manifest GAINED Customers/BirdMovements - // count fields and a Flocks lifecycle split — a schema-1 reader must - // detect the reshape instead of silently misparsing it. - public const int ManifestSchemaVersion = 2; - - // Mirrors SeedFlockTopologyAsync's `wanted` array length. Kept as an - // independent constant (not read off that array) so ComputeExpectedCounts - // needs no DB round-trip to state what the seed intends to produce — it - // still takes `today`/`farmToday` as plain DateOnly inputs (#279 Fix 2, - // for the exact Locked/Submitted split below) but is otherwise pure. - private const int FlockTopologyCount = 2; - - // #627 — mirrors the picker catalog SeedFlockCatalogAsync creates: - // NumberedCatalogFlockCount is what the numbered loop iterates; the other - // three catalog rows (the page-two sentinel, the Depleted, the Archived) - // are fixed-name rows outside the loop. CatalogFlockBandCount is the - // 100-row catalog total the EXPECTED counts read — kept as an independent - // constant for the same reason FlockTopologyCount is: ComputeExpectedCounts - // states the seed's intent without a DB round-trip. - private const int NumberedCatalogFlockCount = 97; - private const int CatalogFlockBandCount = 100; - - // #627 — the customer band SeedCustomersAsync creates on top of the three - // lifecycle customers above (97 zero-padded fillers + the page-two - // sentinel). Total customers is CustomersWanted.Length + this. - private const int CustomerBandCount = CustomerFillerCount + 1; - - // #627 — mirrors SeedExplicitBirdMovementsAsync's explicit row count. - private const int ExplicitBirdMovementsCount = 51; - - // #627 — mirrors SeedFeedAdjustmentsAsync' added adjustment rows on the - // feed lot (its ten pre-existing movements + this = exactly 120 on the - // feed item; the bedding item keeps its two, so account-wide is 122). - private const int FeedAdjustmentBandCount = 110; - - // #627 — the automatic mortality baseline, derived the SAME way - // SeedFlockHistoryAsync generates it: one row per (operational flock, day) - // where d % 5 == 0 for d in DraftWindowDays+1..historyDays — only days - // that are actually SUBMITTED mint a mortality row. An explicit loop, not - // division: the Draft window is part of the rule, and the expected count - // must stay correct if it changes. (90-day history: 18 per flock, 36 - // account-wide; 12-day: 2 per flock.) - private static int AutomaticMortalityPerFlock(int historyDays) - { - var count = 0; - for (var d = DraftWindowDays + 1; d <= historyDays; d++) - if (d % 5 == 0) count++; - return count; - } - - // Mirrors SeedFlockHistoryAsync's per-entry grade-quantity array literal - // (Large/Medium/Small): SubmitDailyEntryHandler mints exactly one egg lot - // per grade line, and the deterministic baseline/cracked/dirty/discarded - // math in SeedFlockHistoryAsync always leaves all three grade buckets > 0 - // for every configured baseline — so every SUBMITTED entry (never a - // still-Draft one; see DraftWindowDays) produces exactly this many lots, - // not merely "at least one" (#279 Fix 2). - private const int GradesPerDailyEntry = 3; - - // #396: submit ALSO mints a lot for each condition counter that resolved - // to a condition grade (DailyEntryKind.Cracked/Dirty) and carried a - // positive count. Kept separate from GradesPerDailyEntry rather than - // folded into it: these are NOT grade lines — no GradeQuantityDto names - // them, and ConditionGradeGuard actively refuses one that does — so the - // two constants answer different questions and drift for different - // reasons. Both counters are structurally positive here (`cracked = 4 + - // d % 3` >= 4, `dirty = 2 + d % 2` >= 2, for every day and baseline), and - // the base seed ships Cracked/Dirty active + saleable, so both resolve on - // every submitted entry — exactly 2, never "up to 2". If a future fixture - // lets either counter reach 0, SubmitDailyEntryHandler skips that lot and - // this must become a per-entry sum instead of a constant. - private const int ConditionLotsPerDailyEntry = 2; - - // Mirrors SeedSalesAsync's two EnsureDraftOrderAsync calls (customer1/2, - // never confirmed) and its three lifecycle EnsureConfirmedOrderAsync - // calls (customer1/2 unpaid + customer3 partially paid) — the recurring - // drip (SeedRecurringOrdersAsync) is added on top via RecurringPointCount. - private const int DraftOrdersCount = 2; - private const int LifecycleConfirmedOrdersCount = 3; - - // Mirrors SeedExpensesAsync's `wanted` array length (the 4 hardcoded - // fixture expenses) — the recurring drip (SeedRecurringExpensesAsync) is - // added on top via RecurringPointCount, same convention as orders above. - private const int LifecycleExpensesCount = 4; - - // SeedRecurringOrdersAsync and SeedRecurringExpensesAsync both loop - // `for (var d = RecurringStartDay; d <= historyDays; d += RecurringCadenceDays)` - // — this is that loop's iteration count, computed once so the manifest's - // expectations never drift from what those two loops actually do. - private static int RecurringPointCount(int historyDays) => - historyDays < RecurringStartDay ? 0 : (historyDays - RecurringStartDay) / RecurringCadenceDays + 1; - - // #638 — DRAIN the sweep; one pass is not enough at depth. - // - // DailyEntryLockSweep is built for the DurableJobWorker: one RunAsync pass - // locks at most DailyEntryLockSweep.BatchSize entries per account and - // returns, and the poll 30 seconds later takes the next batch. A - // run-then-exit seed verb has no poll behind it, so a single pass left - // every eligible entry past the first batch Submitted — while - // ExpectedLockedEntryCount (below) mirrors the sweep's CUTOFF rule and - // therefore expects every eligible entry Locked. Those two agree only - // while `FlockTopologyCount * (HistoryDays - LockAfterDays) <= BatchSize`, - // i.e. HistoryDays <= 107; above it the seed could never validate on any - // database, however clean. - // - // Fixed here rather than by raising BatchSize: that constant is sized for - // the serving-path background worker, and enlarging it to suit a seeder - // would move this ceiling rather than remove it. - // - // Terminates on a pass that locked nothing — the honest "no progress" - // signal (see DailyEntryLockSweep.RunAsync's own contract), which also - // covers the stuck-batch case a due-count terminator would spin on. The - // bound exists only so a future defect that makes a pass report progress - // without making any cannot hang a seed forever; it is derived from the - // same numbers the work itself is, so it can never be the thing that - // fails first on a legitimately deep fixture. - private async Task DrainLockSweepAsync(SimulationOptions sim, CancellationToken ct) - { - var maxPasses = MaxLockSweepPasses(sim.HistoryDays); - for (var pass = 1; pass <= maxPasses; pass++) - if (await lockSweep.RunAsync(ct) == 0) - return; - - throw new InvalidOperationException( - $"Daily-entry lock sweep did not drain in {maxPasses} passes at " - + $"HistoryDays={sim.HistoryDays}; every pass still reported entries locked. " - + "This is a lock-sweep defect, not a seeding depth limit."); - } - - // Upper bound on the passes DrainLockSweepAsync should ever need: enough - // to lock every seeded daily entry across the topology, plus the - // terminating pass that locks nothing, plus one of slack. - // - // Deliberately loose in two directions, because the bound must never be - // the thing that fails first on a legitimate fixture. It counts EVERY - // seeded entry as a candidate while only those past the lock cutoff are - // ever due; and it divides by one BatchSize per pass while a pass sweeps - // every account, so the real per-pass ceiling is higher still. It exists - // solely so a future defect that reports progress without making any - // cannot hang a seed forever. - private static int MaxLockSweepPasses(int historyDays) => - (FlockTopologyCount * Math.Max(historyDays, 0) + DailyEntryLockSweep.BatchSize - 1) - / DailyEntryLockSweep.BatchSize - + 2; - - // Mirrors DailyEntryLockSweep.LockDueEntriesAsync's own cutoff - // (`farmToday.AddDays(-LockAfterDays)`) entry-by-entry, so the manifest's - // expected Locked count is a real derivation from the SAME rule the - // sweep applies — not a ">= 1" floor (#279 Fix 2). Only entries that were - // actually submitted (d > DraftWindowDays) are ever eligible; both flocks - // share the identical day-offset range so one per-flock count scales - // directly by FlockTopologyCount. - private static int ExpectedLockedEntryCount(DateOnly today, DateOnly farmToday, int historyDays) - { - var cutoff = farmToday.AddDays(-DailyEntryLockSweep.LockAfterDays); - var lockedPerFlock = 0; - for (var d = DraftWindowDays + 1; d <= historyDays; d++) - if (today.AddDays(-d) < cutoff) lockedPerFlock++; - return FlockTopologyCount * lockedPerFlock; - } - - private async Task EmitManifestAsync( - Guid accountId, DateOnly today, SimulationOptions sim, CancellationToken ct) - { - var (counts, states) = await ComputeCountsAsync(accountId, ct); - // Same farm-local "today" lookup DailyEntryLockSweep itself just used - // (DrainLockSweepAsync already ran it to exhaustion, above, in - // SeedAsync — #638) — recomputing - // it here mirrors the sweep's OWN cutoff derivation exactly instead - // of approximating it, at the cost of the same vanishingly small - // UTC-midnight-mid-SeedAsync race the class header already documents - // for MinSentinelAgeDays. - var farmToday = clock.TodayInZone(sim.TimeZoneId); - var expected = ComputeExpectedCounts(sim, today, farmToday); - - // Fail-closed: throws on ANY shortfall — a partial/short seed must - // fail startup, not publish a "complete" manifest. - ValidateCounts(counts, states, expected); - - var fingerprint = ComputeFingerprint(sim.Seed, counts, states); - var manifest = new SimulationManifest( - SchemaVersion: ManifestSchemaVersion, - Seed: sim.Seed, - HistoryDays: sim.HistoryDays, - GeneratedAtAnchor: today, - Counts: counts, - LifecycleStates: states, - Complete: true, - Fingerprint: fingerprint); - - if (!string.IsNullOrWhiteSpace(sim.CredentialOutputPath)) - await WriteManifestFileAsync(sim.CredentialOutputPath, manifest, ct); - - logger.LogInformation( - "Simulation seed manifest complete (fingerprint {Fingerprint}).", fingerprint); - - return manifest; - } - - // Everything below except Users/Accounts already sits behind AppDbContext's - // tenant query filter (OnModelCreating), and SeedAsync resolved the - // tenant to accountId at the top — so these queries are already scoped to - // the primary account without an explicit .Where(AccountId == accountId). - // ApplicationUser carries no tenant filter (it's Identity's own table, - // not one of ours), so users are filtered explicitly. Account carries a - // filter too, but IgnoreQueryFilters is used deliberately — the manifest - // needs to see the second, pristine tenant fixture as well (mirrors - // SeedSecondAccountAsync's own IgnoreQueryFilters use). - private async Task<(SimulationManifestCounts Counts, SimulationLifecycleStates States)> ComputeCountsAsync( - Guid accountId, CancellationToken ct) - { - var usersTotal = await access.CountUsersAsync(accountId, ct); - var ownerCount = (await access.ListUsersInRoleAsync(accountId, Roles.Owner, ct)).Count; - var managerCount = (await access.ListUsersInRoleAsync(accountId, Roles.Manager, ct)).Count; - var salesCount = (await access.ListUsersInRoleAsync(accountId, Roles.Sales, ct)).Count; - var readOnlyCount = (await access.ListUsersInRoleAsync(accountId, Roles.ReadOnly, ct)).Count; - // Workers deliberately carry no role row (SeedCastAsync) — derive by - // subtraction rather than a role lookup that would always find none. - var workerCount = usersTotal - ownerCount - managerCount - salesCount - readOnlyCount; - - var accountCount = await farmFixture.CountAccountsAsync(ct); - // #627 — the flock lifecycle split the manifest now certifies exactly - // (INV-5). Every count but the accounts one is tenant-filtered. - var flockCounts = await flockFixture.CountAsync(ct); - var eggCounts = await eggsFixture.CountAsync(ct); - var commerceCounts = await commerceFixture.CountAsync(ct); - var inventoryCounts = await inventoryFixture.CountAsync(ct); - var financeCounts = await financeFixture.CountAsync(ct); - - var counts = new SimulationManifestCounts( - Accounts: accountCount, - Customers: commerceCounts.Customers, - BirdMovements: flockCounts.BirdMovements, - Owners: ownerCount, - Managers: managerCount, - Sales: salesCount, - Workers: workerCount, - ReadOnly: readOnlyCount, - UsersTotal: usersTotal, - Flocks: flockCounts.Flocks, - DailyEntriesTotal: eggCounts.DailyEntries, - EggLots: eggCounts.EggLots, - SalesOrdersTotal: commerceCounts.SalesOrders, - Payments: commerceCounts.Payments, - InventoryItems: inventoryCounts.Items, - InventoryLots: inventoryCounts.Lots, - InventoryMovementsTotal: inventoryCounts.Movements, - FeedUsageRows: inventoryCounts.FeedUsages, - WaterUsageRows: inventoryCounts.WaterUsages, - ExpenseCategories: financeCounts.ExpenseCategories, - Expenses: financeCounts.Expenses); - - var states = new SimulationLifecycleStates( - DailyEntries: new SimulationDailyEntryStates( - eggCounts.DraftEntries, eggCounts.SubmittedEntries, eggCounts.LockedEntries), - Flocks: new SimulationFlockStates( - flockCounts.ActiveFlocks, flockCounts.DepletedFlocks, flockCounts.ArchivedFlocks), - SalesOrders: new SimulationSalesOrderStates( - commerceCounts.DraftOrders, commerceCounts.ConfirmedOrders, commerceCounts.ShippedOrders, - commerceCounts.InvoicedOrders, commerceCounts.CancelledOrders, commerceCounts.VoidedOrders), - InventoryMovements: new SimulationInventoryMovementStates( - inventoryCounts.PurchaseMovements, inventoryCounts.UsageMovements, - inventoryCounts.AdjustmentMovements, inventoryCounts.DiscardMovements)); - - return (counts, states); - } - - // Pure function of SimulationOptions plus the two plain DateOnly anchors - // (today, farmToday) — still no DB access, so a unit test can call it - // with hand-built inputs too. See the class-level comment above - // ValidateCounts for why every field here is DERIVED rather than a - // hardcoded depth-specific number. - private static SimulationExpectedCounts ComputeExpectedCounts(SimulationOptions sim, DateOnly today, DateOnly farmToday) - { - var historyDays = EffectiveHistoryDays(sim); - var recurringPoints = RecurringPointCount(historyDays); - - var dailyEntriesTotal = FlockTopologyCount * historyDays; - var draftEntries = FlockTopologyCount * DraftWindowDays; - var lockedEntries = ExpectedLockedEntryCount(today, farmToday, historyDays); - var submittedEntries = dailyEntriesTotal - draftEntries - lockedEntries; - // Entries that ever reached Submitted (whether still Submitted or - // since moved to Locked) — the population SubmitDailyEntryHandler - // actually minted egg lots for; Draft entries never call it. - var submittedOrLockedEntries = dailyEntriesTotal - draftEntries; - - var salesOrdersConfirmed = LifecycleConfirmedOrdersCount + recurringPoints; - var feedUsageRows = FlockTopologyCount * FeedUsageDays; - // One Purchase + one Adjustment/Discard movement per inventory item, - // plus one Usage movement per feed usage row — EnsureOpeningPurchaseAsync - // creates exactly one lot per item, so FIFO in RecordFeedUsageHandler - // never needs to split a single usage across more than one lot. - // - // #627 — plus the feed lot's explicit adjustment band (the bedding - // item is untouched by it): the feed item lands on exactly 10 + - // FeedAdjustmentBandCount = 120 movements, the bedding item keeps its - // 2, and the account-wide total below is 122 at the fixture scale. - var inventoryMovementsTotal = - InventoryItemsWanted.Length + InventoryItemsWanted.Length - + FeedAdjustmentBandCount + feedUsageRows; - - return new SimulationExpectedCounts( - Accounts: 2, // primary + SecondAccountId — never a third on a re-run. - Owners: 1, // the reused seeded admin — this seeder never creates a second Owner. - Managers: sim.Managers, - Sales: sim.Sales, - Workers: sim.Workers, - ReadOnly: sim.ReadOnly, - UsersTotal: 1 + sim.Managers + sim.Sales + sim.Workers + sim.ReadOnly, - // #627 — two operational houses + the exact picker catalog. - Flocks: FlockTopologyCount + CatalogFlockBandCount, - FlocksActive: FlockTopologyCount + NumberedCatalogFlockCount + 1, - FlocksDepleted: 1, - FlocksArchived: 1, - Customers: CustomersWanted.Length + CustomerBandCount, - // #627 — automatic mortality baseline (explicit day loop, per - // flock) plus the explicit adjustment band on the first - // operational flock. No other flock carries explicit rows, and no - // cull row is ever seeded, so this is the account-wide total. - BirdMovements: FlockTopologyCount * AutomaticMortalityPerFlock(historyDays) - + ExplicitBirdMovementsCount, - DailyEntriesTotal: dailyEntriesTotal, - DraftEntries: draftEntries, - SubmittedEntries: submittedEntries, - LockedEntries: lockedEntries, - EggLots: (GradesPerDailyEntry + ConditionLotsPerDailyEntry) * submittedOrLockedEntries, - SalesOrdersTotal: DraftOrdersCount + salesOrdersConfirmed, - SalesOrdersDraft: DraftOrdersCount, - SalesOrdersConfirmed: salesOrdersConfirmed, - Payments: 1, // exactly one partial payment (EnsurePartialPaymentAsync). - InventoryItems: InventoryItemsWanted.Length, - InventoryLots: InventoryItemsWanted.Length, // one opening lot per item. - InventoryPurchaseMovements: InventoryItemsWanted.Length, - InventoryAdjustmentOrDiscardMovements: InventoryItemsWanted.Length - + FeedAdjustmentBandCount, // one discard + the feed adjustment band - InventoryUsageMovements: feedUsageRows, - InventoryMovementsTotal: inventoryMovementsTotal, - FeedUsageRows: feedUsageRows, - WaterUsageRows: FlockTopologyCount * WaterUsageDays, - ExpenseCategories: ExpenseCategoriesWanted.Length, - Expenses: LifecycleExpensesCount + recurringPoints); - } - - // Pure, no DB access — a test can hand this a synthetic short - // SimulationManifestCounts/SimulationLifecycleStates and assert it throws, - // proving the fail-closed path without needing to sabotage a real seed run. - // The fail-closed gate, run AFTER the lock sweep: asserts every count EXACTLY - // (including the Draft/Submitted/Locked split) and throws on any shortfall, - // collecting every mismatch so one failed run reports the WHOLE shortfall. - // (#279: the "already seeded?" question is answered by the durable - // SimulationSeedState.CompletedAtUtc marker, NOT by a tolerant count probe, - // so this method has a single exact mode again.) - internal static void ValidateCounts( - SimulationManifestCounts counts, SimulationLifecycleStates states, SimulationExpectedCounts expected) - { - var failures = new List(); - void Check(bool ok, string message) - { - if (!ok) failures.Add(message); - } - - Check(counts.Accounts == expected.Accounts, - $"accounts: expected exactly {expected.Accounts}, got {counts.Accounts}"); - Check(counts.Owners == expected.Owners, $"users.owners: expected {expected.Owners}, got {counts.Owners}"); - Check(counts.Managers == expected.Managers, - $"users.managers: expected {expected.Managers}, got {counts.Managers}"); - Check(counts.Sales == expected.Sales, $"users.sales: expected {expected.Sales}, got {counts.Sales}"); - Check(counts.Workers == expected.Workers, $"users.workers: expected {expected.Workers}, got {counts.Workers}"); - Check(counts.ReadOnly == expected.ReadOnly, - $"users.readOnly: expected {expected.ReadOnly}, got {counts.ReadOnly}"); - Check(counts.UsersTotal == expected.UsersTotal, - $"users.total: expected {expected.UsersTotal}, got {counts.UsersTotal}"); - Check(counts.Flocks == expected.Flocks, $"flocks: expected {expected.Flocks}, got {counts.Flocks}"); - // #627 — the flock lifecycle split, exact (INV-5) plus a reconciliation - // sum so the three bands cannot hide a fourth status the counts don't - // name. - Check(states.Flocks.Active == expected.FlocksActive, - $"flocks.active: expected {expected.FlocksActive}, got {states.Flocks.Active}"); - Check(states.Flocks.Depleted == expected.FlocksDepleted, - $"flocks.depleted: expected {expected.FlocksDepleted}, got {states.Flocks.Depleted}"); - Check(states.Flocks.Archived == expected.FlocksArchived, - $"flocks.archived: expected {expected.FlocksArchived}, got {states.Flocks.Archived}"); - var flockStateSum = states.Flocks.Active + states.Flocks.Depleted + states.Flocks.Archived; - Check(flockStateSum == counts.Flocks, - $"flocks reconciliation: active+depleted+archived ({flockStateSum}) != total ({counts.Flocks})"); - Check(counts.Customers == expected.Customers, - $"customers: expected {expected.Customers}, got {counts.Customers}"); - Check(counts.BirdMovements == expected.BirdMovements, - $"birdMovements: expected {expected.BirdMovements}, got {counts.BirdMovements}"); - Check(counts.DailyEntriesTotal == expected.DailyEntriesTotal, - $"dailyEntries.total: expected {expected.DailyEntriesTotal}, got {counts.DailyEntriesTotal}"); - Check(states.DailyEntries.Draft == expected.DraftEntries, - $"dailyEntries.draft: expected {expected.DraftEntries}, got {states.DailyEntries.Draft}"); - Check(states.DailyEntries.Submitted == expected.SubmittedEntries, - $"dailyEntries.submitted: expected {expected.SubmittedEntries}, got {states.DailyEntries.Submitted}"); - Check(states.DailyEntries.Locked == expected.LockedEntries, - $"dailyEntries.locked: expected {expected.LockedEntries}, got {states.DailyEntries.Locked}"); - var dailyEntriesSum = states.DailyEntries.Draft + states.DailyEntries.Submitted + states.DailyEntries.Locked; - Check(dailyEntriesSum == counts.DailyEntriesTotal, - $"dailyEntries reconciliation: draft+submitted+locked ({dailyEntriesSum}) != total ({counts.DailyEntriesTotal})"); - Check(counts.EggLots == expected.EggLots, $"eggLots: expected {expected.EggLots}, got {counts.EggLots}"); - Check(counts.SalesOrdersTotal == expected.SalesOrdersTotal, - $"salesOrders.total: expected {expected.SalesOrdersTotal}, got {counts.SalesOrdersTotal}"); - Check(states.SalesOrders.Draft == expected.SalesOrdersDraft, - $"salesOrders.draft: expected {expected.SalesOrdersDraft}, got {states.SalesOrders.Draft}"); - Check(states.SalesOrders.Confirmed == expected.SalesOrdersConfirmed, - $"salesOrders.confirmed: expected {expected.SalesOrdersConfirmed}, got {states.SalesOrders.Confirmed}"); - // The seeder never ships/invoices/cancels/voids anything it creates - // (SeedSalesAsync's own header comment) — these terminal states must - // stay at exactly zero, not merely unchecked. - Check(states.SalesOrders.Shipped == 0, $"salesOrders.shipped: expected 0, got {states.SalesOrders.Shipped}"); - Check(states.SalesOrders.Invoiced == 0, $"salesOrders.invoiced: expected 0, got {states.SalesOrders.Invoiced}"); - Check(states.SalesOrders.Cancelled == 0, $"salesOrders.cancelled: expected 0, got {states.SalesOrders.Cancelled}"); - Check(states.SalesOrders.Voided == 0, $"salesOrders.voided: expected 0, got {states.SalesOrders.Voided}"); - var salesOrdersSum = states.SalesOrders.Draft + states.SalesOrders.Confirmed + states.SalesOrders.Shipped - + states.SalesOrders.Invoiced + states.SalesOrders.Cancelled + states.SalesOrders.Voided; - Check(salesOrdersSum == counts.SalesOrdersTotal, - $"salesOrders reconciliation: sum of states ({salesOrdersSum}) != total ({counts.SalesOrdersTotal})"); - Check(counts.Payments == expected.Payments, $"payments: expected {expected.Payments}, got {counts.Payments}"); - Check(counts.InventoryItems == expected.InventoryItems, - $"inventoryItems: expected {expected.InventoryItems}, got {counts.InventoryItems}"); - Check(counts.InventoryLots == expected.InventoryLots, - $"inventoryLots: expected {expected.InventoryLots}, got {counts.InventoryLots}"); - Check(states.InventoryMovements.Purchase == expected.InventoryPurchaseMovements, - $"inventoryMovements.purchase: expected {expected.InventoryPurchaseMovements}, got {states.InventoryMovements.Purchase}"); - Check(states.InventoryMovements.Adjustment + states.InventoryMovements.Discard - == expected.InventoryAdjustmentOrDiscardMovements, - $"inventoryMovements.adjustment+discard: expected {expected.InventoryAdjustmentOrDiscardMovements}, " + - $"got {states.InventoryMovements.Adjustment + states.InventoryMovements.Discard}"); - Check(states.InventoryMovements.Usage == expected.InventoryUsageMovements, - $"inventoryMovements.usage: expected {expected.InventoryUsageMovements}, got {states.InventoryMovements.Usage}"); - Check(counts.InventoryMovementsTotal == expected.InventoryMovementsTotal, - $"inventoryMovements.total: expected {expected.InventoryMovementsTotal}, got {counts.InventoryMovementsTotal}"); - var inventoryMovementsSum = states.InventoryMovements.Purchase + states.InventoryMovements.Usage - + states.InventoryMovements.Adjustment + states.InventoryMovements.Discard; - Check(inventoryMovementsSum == counts.InventoryMovementsTotal, - $"inventoryMovements reconciliation: sum of states ({inventoryMovementsSum}) != total ({counts.InventoryMovementsTotal})"); - Check(counts.FeedUsageRows == expected.FeedUsageRows, - $"feedUsageRows: expected {expected.FeedUsageRows}, got {counts.FeedUsageRows}"); - Check(counts.WaterUsageRows == expected.WaterUsageRows, - $"waterUsageRows: expected {expected.WaterUsageRows}, got {counts.WaterUsageRows}"); - Check(counts.ExpenseCategories == expected.ExpenseCategories, - $"expenseCategories: expected {expected.ExpenseCategories}, got {counts.ExpenseCategories}"); - Check(counts.Expenses == expected.Expenses, $"expenses: expected {expected.Expenses}, got {counts.Expenses}"); - - if (failures.Count > 0) - throw new InvalidOperationException( - "Simulation seed completion check failed — the seed is short/partial and must NOT be " + - "marked complete: " + string.Join("; ", failures)); - } - - private static readonly JsonSerializerOptions FingerprintJsonOptions = new(JsonSerializerDefaults.Web); - - // Stable hash of counts+seed (same Convert.ToHexString(SHA256.HashData(...)) - // convention as FarmLogo.ContentHash / IdempotencyMiddleware.Sha256) — a - // re-run that recomputes the SAME counts from the SAME seed always - // produces the SAME fingerprint (idempotent), and any real shortfall - // changes it. - // - // #279 review Fix 3 (Agent A): states.DailyEntries (the Draft/Submitted/ - // Locked split) is deliberately EXCLUDED — it depends on where the - // farm-local midnight lock-sweep boundary (DailyEntryLockSweep. - // LockAfterDays) falls relative to this seeder's UTC "today" anchor at - // the moment the sweep runs, so two otherwise-identical seed runs - // straddling that boundary would fingerprint differently even though - // every seeder-controlled COUNT (including DailyEntriesTotal) matches — - // a latent flake, and not reproducible day-to-day. states.SalesOrders and - // states.InventoryMovements stay in the hash: nothing time-based ever - // moves a sales order or inventory movement between states after the - // seeder writes it, so those splits are exactly as stable as counts. The - // split itself is still reported — just via manifest.LifecycleStates - // (#277), not folded into the fingerprint. Internal (not private) so - // SimulationSeederTests can probe it directly rather than only through - // the wall-clock-dependent integration path. - internal static string ComputeFingerprint(int seed, SimulationManifestCounts counts, SimulationLifecycleStates states) - { - // #627 — states.Flocks joins the canonical payload: nothing time-based - // moves a flock between Active/Depleted/Archived after the seeder - // writes it, so the split is exactly as stable as the other included - // states. states.DailyEntries stays deliberately EXCLUDED (the - // sweep-boundary flake the comment above documents). - var canonical = JsonSerializer.Serialize( - new { seed, counts, states.SalesOrders, states.InventoryMovements, states.Flocks }, FingerprintJsonOptions); - return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); - } - - private static readonly JsonSerializerOptions ManifestFileJsonOptions = - new(JsonSerializerDefaults.Web) { WriteIndented = true }; - - private async Task WriteManifestFileAsync(string path, SimulationManifest manifest, CancellationToken ct) - { - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory)) - Directory.CreateDirectory(directory); - - // Atomic: serialize the whole manifest (complete + fingerprint - // included — they're computed last, in EmitManifestAsync, before this - // is ever called) to a temp file in the SAME directory, then rename - // over the final path. A reader (the #243 findings header, #277's - // Playwright suite) never observes a partially-written manifest — - // File.Move onto an existing path is a single filesystem rename, not - // a truncate-then-write. - var tempPath = path + ".tmp"; - await using (var stream = File.Create(tempPath)) - await JsonSerializer.SerializeAsync(stream, manifest, ManifestFileJsonOptions, ct); - File.Move(tempPath, path, overwrite: true); - - logger.LogInformation("Simulation seed manifest written to {Path}.", path); - } } - -public sealed record SimulationDailyEntryStates(int Draft, int Submitted, int Locked); - -public sealed record SimulationSalesOrderStates( - int Draft, int Confirmed, int Shipped, int Invoiced, int Cancelled, int Voided); - -public sealed record SimulationInventoryMovementStates(int Purchase, int Usage, int Adjustment, int Discard); - -public sealed record SimulationFlockStates(int Active, int Depleted, int Archived); - -public sealed record SimulationLifecycleStates( - SimulationDailyEntryStates DailyEntries, - SimulationSalesOrderStates SalesOrders, - SimulationInventoryMovementStates InventoryMovements, - SimulationFlockStates Flocks); - -public sealed record SimulationManifestCounts( - int Accounts, - int Customers, - int BirdMovements, - int Owners, - int Managers, - int Sales, - int Workers, - int ReadOnly, - int UsersTotal, - int Flocks, - int DailyEntriesTotal, - int EggLots, - int SalesOrdersTotal, - int Payments, - int InventoryItems, - int InventoryLots, - int InventoryMovementsTotal, - int FeedUsageRows, - int WaterUsageRows, - int ExpenseCategories, - int Expenses); - -// Not persisted directly — folded into ValidateCounts' failure messages -// above. Kept as its own type (rather than loose parameters) so -// ComputeExpectedCounts and ValidateCounts share one shape, and so a test can -// hand-build one for the fail-closed assertion. -public sealed record SimulationExpectedCounts( - int Accounts, - int Owners, - int Managers, - int Sales, - int Workers, - int ReadOnly, - int UsersTotal, - int Flocks, - int FlocksActive, - int FlocksDepleted, - int FlocksArchived, - int Customers, - int BirdMovements, - int DailyEntriesTotal, - int DraftEntries, - int SubmittedEntries, - int LockedEntries, - int EggLots, - int SalesOrdersTotal, - int SalesOrdersDraft, - int SalesOrdersConfirmed, - int Payments, - int InventoryItems, - int InventoryLots, - int InventoryPurchaseMovements, - int InventoryAdjustmentOrDiscardMovements, - int InventoryUsageMovements, - int InventoryMovementsTotal, - int FeedUsageRows, - int WaterUsageRows, - int ExpenseCategories, - int Expenses); - -// #243 Task 3e — the seed's machine-readable completion artifact. schemaVersion -// lets a future breaking reshape be detected by consumers instead of silently -// misparsed; complete/fingerprint are always the last fields computed -// (EmitManifestAsync) and the manifest is only ever constructed AFTER -// ValidateCounts passes — there is no code path that produces one of these -// with complete: true for a short/partial seed. -public sealed record SimulationManifest( - int SchemaVersion, - int Seed, - int HistoryDays, - DateOnly GeneratedAtAnchor, - SimulationManifestCounts Counts, - SimulationLifecycleStates LifecycleStates, - bool Complete, - string Fingerprint); diff --git a/tests/Cluckwork.Application.Tests/Architecture/FileSizeLimitTests.cs b/tests/Cluckwork.Application.Tests/Architecture/FileSizeLimitTests.cs index bb8f89fc7..f5429584f 100644 --- a/tests/Cluckwork.Application.Tests/Architecture/FileSizeLimitTests.cs +++ b/tests/Cluckwork.Application.Tests/Architecture/FileSizeLimitTests.cs @@ -25,7 +25,6 @@ public sealed partial class FileSizeLimitTests private static readonly Dictionary Legacy = new(StringComparer.Ordinal) { ["src/Cluckwork.Api/Program.cs"] = 685, - ["src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs"] = 2785, ["web/src/api/cluckwork.ts"] = 1472, ["web/src/components/NamedEntityPicker.tsx"] = 1531, ["web/src/routes/UsersPage.tsx"] = 1361, diff --git a/tools/simulation/ui/specs/named-entity-picker.spec.ts b/tools/simulation/ui/specs/named-entity-picker.spec.ts index ca089a58a..2cdbcc6ad 100644 --- a/tools/simulation/ui/specs/named-entity-picker.spec.ts +++ b/tools/simulation/ui/specs/named-entity-picker.spec.ts @@ -16,7 +16,7 @@ // // 1. Unfiltered PAGING reaches the flock sentinel: with eligibility // active-and-depleted the picker sees 101 non-archived flocks (comment at -// SimulationDataSeeder.cs ~L824), and its 50-row pages put the +// SimulationDataSeeder.Flocks.cs ~L97), and its 50-row pages put the // lexically-last sentinel on the THIRD page (50 + 50 + 1) — two Load more // clicks, not one. This is also the test the paging mutant // (named-entity-picker-paging-broken, mutants.ts) must turn red.