From 03f4f106479f91f10b0e442fca1917892497a1d3 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 16 Jul 2026 14:54:03 -0700 Subject: [PATCH 1/2] =?UTF-8?q?F10:=20mortality/culling=20=E2=80=94=20bird?= =?UTF-8?q?=20movement=20ledger=20+=20current=20flock=20counts=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily entry captured a mortality count that went nowhere and flock counts never moved from InitialCount. Domain - BirdMovement: append-only ledger row (flock, date, type Mortality/Cull/Adjustment, quantity = birds removed, note). Rows are immutable — corrections are new Adjustment rows (which may be negative to add birds back) — so no Version token per the aggregate-mutation rule's rationale. Application - SubmitDailyEntry also writes a Mortality movement for the entry's date when MortalityCount > 0, in the same transaction as egg lots: the existing submit Version race covers it, so a losing submit rolls back its movement with its lots. Draft mortality is not a movement (submit freezes the day). - RecordBirdMovement handler for manual Cull/Adjustment; manual Mortality is rejected (would double-count at submit). Flock lifecycle gate reuses CanRecordProductionOn: depleted flocks accept backfill dated on/before depletion, archived accept nothing. API - POST/GET /flocks/{id}/movements (paged, newest first; 404 for unknown/foreign flocks). - Flock responses gain CurrentBirds = InitialCount − Σ quantities; list uses one grouped query. Web - Flocks screen: Birds column shows current count (initial alongside once they differ); per-flock "birds" ledger panel with movement history and a cull/adjustment form. Scoped idempotency keys, refresh-before-rotation. Tests (+4 -> 119): submit generates exactly one movement and zero- mortality days none, draft mortality not counted, cull + negative adjustment math on get and list, validation and lifecycle gating, parallel-submit race leaves exactly one movement. Verified in chromium against the real stack: submit with mortality dropped House 2 400->394 on the Flocks screen; cull -> 384; negative adjustment -> 386; ledger shows all three row types. Closes #54 --- .../Endpoints/Flocks/FlockEndpoints.cs | 74 +- src/Cluckwork.Api/Program.cs | 4 + .../SubmitDailyEntryHandler.cs | 22 +- .../Flocks/IBirdMovementRepository.cs | 17 + .../RecordBirdMovementCommand.cs | 11 + .../RecordBirdMovementHandler.cs | 36 + .../RecordBirdMovementValidator.cs | 38 + src/Cluckwork.Domain/Flocks/BirdMovement.cs | 50 + .../Persistence/AppDbContext.cs | 2 + .../Configurations/FlockConfiguration.cs | 23 + ...0260716214949_AddBirdMovements.Designer.cs | 921 ++++++++++++++++++ .../20260716214949_AddBirdMovements.cs | 55 ++ .../Migrations/AppDbContextModelSnapshot.cs | 45 + .../Repositories/BirdMovementRepository.cs | 43 + .../BirdMovementTests.cs | 176 ++++ web/src/api/cluckwork.ts | 24 + web/src/routes/FlocksPage.tsx | 109 ++- 17 files changed, 1635 insertions(+), 15 deletions(-) create mode 100644 src/Cluckwork.Application/Features/Flocks/IBirdMovementRepository.cs create mode 100644 src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementCommand.cs create mode 100644 src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs create mode 100644 src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementValidator.cs create mode 100644 src/Cluckwork.Domain/Flocks/BirdMovement.cs create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.Designer.cs create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.cs create mode 100644 src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs create mode 100644 tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs diff --git a/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs b/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs index 10ad2d4fe..a4af18757 100644 --- a/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs +++ b/src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs @@ -4,6 +4,7 @@ namespace Cluckwork.Api.Endpoints.Flocks; using Cluckwork.Application.Features.Flocks.ArchiveFlock; using Cluckwork.Application.Features.Flocks.CreateFlock; using Cluckwork.Application.Features.Flocks.DepleteFlock; +using Cluckwork.Application.Features.Flocks.RecordBirdMovement; using Cluckwork.Application.Features.Flocks.UpdateFlock; using Cluckwork.Domain.Flocks; using Cluckwork.Infrastructure.Persistence; @@ -43,6 +44,14 @@ public static RouteGroupBuilder MapFlockEndpoints(this RouteGroupBuilder group) .WithName("ArchiveFlock") .WithSummary("Archive a flock: hidden from pickers and dashboard, visible in the management view."); + group.MapPost("/{id:guid}/movements", RecordMovement) + .WithName("RecordBirdMovement") + .WithSummary("Record a manual bird movement (Cull or Adjustment; mortality is generated from submitted daily entries)."); + + group.MapGet("/{id:guid}/movements", ListMovements) + .WithName("ListBirdMovements") + .WithSummary("List a flock's bird movements, newest first (paged)."); + return group; } @@ -69,7 +78,7 @@ private static async Task CreateFlock( } private static async Task ListFlocks( - IFlockRepository flocks, TenantContext tenant, + IFlockRepository flocks, IBirdMovementRepository movements, TenantContext tenant, CancellationToken ct, int? limit = null, int? offset = null, bool includeArchived = false) { if (!tenant.IsResolved) return Results.Unauthorized(); @@ -78,15 +87,59 @@ private static async Task ListFlocks( var skip = Math.Max(offset ?? 0, 0); var list = await flocks.ListAsync(take, skip, includeArchived, ct); - return Results.Ok(list.Select(ToResponse)); + var removed = await movements.RemovedByFlockAsync(ct); + return Results.Ok(list.Select(f => + ToResponse(f, removed.GetValueOrDefault(f.Id, 0)))); } private static async Task GetFlock( - Guid id, IFlockRepository flocks, TenantContext tenant, CancellationToken ct) + Guid id, IFlockRepository flocks, IBirdMovementRepository movements, + TenantContext tenant, CancellationToken ct) { if (!tenant.IsResolved) return Results.Unauthorized(); var flock = await flocks.GetByIdAsync(id, ct); - return flock is null ? Results.NotFound() : Results.Ok(ToResponse(flock)); + if (flock is null) return Results.NotFound(); + var removed = await movements.RemovedForFlockAsync(id, ct); + return Results.Ok(ToResponse(flock, removed)); + } + + private static async Task RecordMovement( + Guid id, + RecordBirdMovementRequest request, + RecordBirdMovementHandler handler, + IValidator validator, + TenantContext tenant, + CancellationToken ct) + { + if (!tenant.IsResolved) return Results.Unauthorized(); + + var command = new RecordBirdMovementCommand( + id, request.Date, request.Type, request.Quantity, request.Note); + + var validation = await validator.ValidateAsync(command, ct); + if (!validation.IsValid) + return Results.ValidationProblem(validation.ToDictionary()); + + var result = await handler.HandleAsync(command, tenant.AccountId, ct); + return result.IsSuccess + ? Results.Created($"/api/v1/flocks/{id}/movements", new { Id = result.Value }) + : MapFailure(result.Error); + } + + private static async Task ListMovements( + Guid id, IFlockRepository flocks, IBirdMovementRepository movements, + TenantContext tenant, CancellationToken ct, int? limit = null, int? offset = null) + { + if (!tenant.IsResolved) return Results.Unauthorized(); + // 404 for a foreign/unknown flock rather than an empty ledger. + if (await flocks.GetByIdAsync(id, ct) is null) return Results.NotFound(); + + var take = Math.Clamp(limit ?? DefaultPageSize, 1, MaxPageSize); + var skip = Math.Max(offset ?? 0, 0); + + var list = await movements.ListByFlockAsync(id, take, skip, ct); + return Results.Ok(list.Select(m => new BirdMovementResponse( + m.Id, m.FlockId, m.Date, m.Type.ToString(), m.Quantity, m.Note))); } private static async Task UpdateFlock( @@ -137,9 +190,9 @@ private static IResult MapFailure(Cluckwork.Domain.Common.Error error) : Results.Problem(error.Description, statusCode: 422, title: error.Code); } - private static FlockResponse ToResponse(Flock f) => new( + private static FlockResponse ToResponse(Flock f, long removed) => new( f.Id, f.FarmId, f.HouseId, f.Name, f.Breed, - f.PlacementDate, f.InitialCount, f.Status.ToString()); + f.PlacementDate, f.InitialCount, f.InitialCount - removed, f.Status.ToString()); } public sealed record CreateFlockRequest( @@ -154,6 +207,13 @@ public sealed record UpdateFlockRequest( DateOnly PlacementDate, int InitialCount); +// CurrentBirds = InitialCount − Σ movement quantities (bird ledger, #54). public sealed record FlockResponse( Guid Id, Guid FarmId, Guid HouseId, string Name, string Breed, - DateOnly PlacementDate, int InitialCount, string Status); + DateOnly PlacementDate, int InitialCount, long CurrentBirds, string Status); + +public sealed record RecordBirdMovementRequest( + DateOnly Date, string Type, int Quantity, string? Note = null); + +public sealed record BirdMovementResponse( + Guid Id, Guid FlockId, DateOnly Date, string Type, int Quantity, string? Note); diff --git a/src/Cluckwork.Api/Program.cs b/src/Cluckwork.Api/Program.cs index 1dba9e503..1b1d5ef54 100644 --- a/src/Cluckwork.Api/Program.cs +++ b/src/Cluckwork.Api/Program.cs @@ -23,6 +23,7 @@ using Cluckwork.Application.Features.Flocks.ArchiveFlock; using Cluckwork.Application.Features.Flocks.CreateFlock; using Cluckwork.Application.Features.Flocks.DepleteFlock; +using Cluckwork.Application.Features.Flocks.RecordBirdMovement; using Cluckwork.Application.Features.Flocks.UpdateFlock; using Cluckwork.Application.Features.Sales; using Cluckwork.Application.Features.Sales.AddOrderItem; @@ -131,6 +132,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -144,6 +146,7 @@ builder.Services.AddScoped, CreateEggGradeValidator>(); builder.Services.AddScoped, UpdateEggGradeValidator>(); builder.Services.AddScoped, UpdateFlockValidator>(); +builder.Services.AddScoped, RecordBirdMovementValidator>(); // --- Handlers (direct — no mediator, tech spec §2.1) --- builder.Services.AddScoped(); @@ -162,6 +165,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --- Startup seed (single-farm MVP) --- builder.Services.Configure(builder.Configuration.GetSection(SeedOptions.SectionName)); diff --git a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs index aad86d97d..7f2cc456e 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs @@ -2,18 +2,22 @@ namespace Cluckwork.Application.Features.DailyEntries.SubmitDailyEntry; using Cluckwork.Application.Common; using Cluckwork.Application.Features.EggLots; +using Cluckwork.Application.Features.Flocks; using Cluckwork.Domain.Common; using Cluckwork.Domain.Eggs; +using Cluckwork.Domain.Flocks; // The production -> stock bridge (#8): submitting a daily entry turns its grade -// lines into egg lots, one lot per grade, dated by the entry. Submit + lot -// creation commit atomically; a failed save leaves the entry Draft with no lots. +// lines into egg lots, one lot per grade, dated by the entry, and its mortality +// count into a bird-movement ledger row (#54). Submit + lots + movement commit +// atomically; a failed save leaves the entry Draft with nothing generated. // Duplicate submits are blocked by the state machine (NotDraft) and, under -// concurrency, by the entry's optimistic Version token — so lots are never -// generated twice. +// concurrency, by the entry's optimistic Version token — so lots and movements +// are never generated twice. public sealed class SubmitDailyEntryHandler( IDailyEntryRepository entries, IEggLotRepository eggLots, + IBirdMovementRepository birdMovements, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -39,6 +43,16 @@ public async Task> HandleAsync( lotIds.Add(lot.Id); } + // The day's mortality becomes a ledger row so the flock's current count + // reflects it. Zero-mortality days write nothing. + if (entry.MortalityCount > 0) + { + await birdMovements.AddAsync(BirdMovement.Create( + Guid.NewGuid(), accountId, entry.FlockId, + entry.Date, BirdMovementType.Mortality, entry.MortalityCount, + note: "Daily entry mortality"), ct); + } + // A concurrent submit that loses the Version race throws // DbUpdateConcurrencyException here; the API's global error handler maps it // to 409 and nothing from the losing request is persisted. A retry then diff --git a/src/Cluckwork.Application/Features/Flocks/IBirdMovementRepository.cs b/src/Cluckwork.Application/Features/Flocks/IBirdMovementRepository.cs new file mode 100644 index 000000000..5f7f5a518 --- /dev/null +++ b/src/Cluckwork.Application/Features/Flocks/IBirdMovementRepository.cs @@ -0,0 +1,17 @@ +namespace Cluckwork.Application.Features.Flocks; + +using Cluckwork.Application.Common; +using Cluckwork.Domain.Flocks; + +public interface IBirdMovementRepository : IRepository +{ + // Newest first (date, then id) — ledger browsing. + Task> ListByFlockAsync( + Guid flockId, int limit, int offset, CancellationToken ct = default); + + // Net birds removed per flock (Σ Quantity), one grouped query for lists. + // Flocks with no movements are absent from the result. + Task> RemovedByFlockAsync(CancellationToken ct = default); + + Task RemovedForFlockAsync(Guid flockId, CancellationToken ct = default); +} diff --git a/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementCommand.cs b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementCommand.cs new file mode 100644 index 000000000..51c26a43e --- /dev/null +++ b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementCommand.cs @@ -0,0 +1,11 @@ +namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement; + +// Manual ledger entry: culls and corrections. Mortality rows are generated +// from submitted daily entries only — a manual Mortality type would double +// count once the day is submitted. +public sealed record RecordBirdMovementCommand( + Guid FlockId, + DateOnly Date, + string Type, + int Quantity, + string? Note); diff --git a/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs new file mode 100644 index 000000000..1d974d018 --- /dev/null +++ b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementHandler.cs @@ -0,0 +1,36 @@ +namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement; + +using Cluckwork.Application.Common; +using Cluckwork.Application.Features.Flocks; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Flocks; + +public sealed class RecordBirdMovementHandler( + IFlockRepository flocks, + IBirdMovementRepository movements, + IUnitOfWork unitOfWork) +{ + public async Task> HandleAsync( + RecordBirdMovementCommand command, Guid accountId, CancellationToken ct) + { + var flock = await flocks.GetByIdAsync(command.FlockId, ct); + if (flock is null) + return Result.Failure(Error.NotFound(nameof(Flock), command.FlockId)); + + // Same lifecycle gate as daily entries (#47): depleted flocks accept + // backfill dated on/before depletion; archived flocks accept nothing. + if (!flock.CanRecordProductionOn(command.Date)) + return Result.Failure(Error.Validation( + "BirdMovement.FlockNotActive", + $"Flock '{flock.Name}' is {flock.Status.ToString().ToLowerInvariant()} — movements cannot be recorded for {command.Date:yyyy-MM-dd}.")); + + var type = Enum.Parse(command.Type, ignoreCase: true); + var movement = BirdMovement.Create( + Guid.NewGuid(), accountId, flock.Id, + command.Date, type, command.Quantity, command.Note); + + await movements.AddAsync(movement, ct); + await unitOfWork.SaveChangesAsync(ct); + return Result.Success(movement.Id); + } +} diff --git a/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementValidator.cs b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementValidator.cs new file mode 100644 index 000000000..b0134b00b --- /dev/null +++ b/src/Cluckwork.Application/Features/Flocks/RecordBirdMovement/RecordBirdMovementValidator.cs @@ -0,0 +1,38 @@ +namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement; + +using Cluckwork.Domain.Flocks; +using FluentValidation; + +public sealed class RecordBirdMovementValidator : AbstractValidator +{ + private static readonly string[] ManualTypes = [nameof(BirdMovementType.Cull), nameof(BirdMovementType.Adjustment)]; + + public RecordBirdMovementValidator() + { + RuleFor(c => c.FlockId).NotEmpty(); + + RuleFor(c => c.Type) + .Must(t => ManualTypes.Contains(t, StringComparer.OrdinalIgnoreCase)) + .WithMessage("Movement type must be Cull or Adjustment (mortality is generated from daily entries)."); + + RuleFor(c => c.Quantity) + .NotEqual(0) + .WithMessage("Quantity cannot be zero."); + + RuleFor(c => c.Quantity) + .GreaterThan(0) + .When(c => nameof(BirdMovementType.Cull).Equals(c.Type, StringComparison.OrdinalIgnoreCase)) + .WithMessage("Culls remove birds — quantity must be positive."); + + RuleFor(c => c.Date) + .NotEqual(default(DateOnly)) + .WithMessage("Date is required.") + // Same +1-day slack as daily entries (client clock skew). + .LessThanOrEqualTo(_ => DateOnly.FromDateTime(DateTime.UtcNow.Date).AddDays(1)) + .WithMessage("Date cannot be in the future."); + + RuleFor(c => c.Note) + .Must(n => n is null || n.Trim().Length <= BirdMovement.MaxNoteLength) + .WithMessage($"Note cannot exceed {BirdMovement.MaxNoteLength} characters."); + } +} diff --git a/src/Cluckwork.Domain/Flocks/BirdMovement.cs b/src/Cluckwork.Domain/Flocks/BirdMovement.cs new file mode 100644 index 000000000..cde1b2d6b --- /dev/null +++ b/src/Cluckwork.Domain/Flocks/BirdMovement.cs @@ -0,0 +1,50 @@ +namespace Cluckwork.Domain.Flocks; + +// Append-only bird ledger row (#54): every change to a flock's living count is +// a movement — mortality (generated from submitted daily entries), culls, and +// manual adjustments. Rows are never edited; corrections are new Adjustment +// rows with the opposite sign. No Version token needed: immutable after Create. +// +// Quantity is "birds removed" — positive shrinks the flock. Adjustment rows +// may be negative to add birds back (miscount corrections). +public sealed class BirdMovement : AggregateRoot +{ + public const int MaxNoteLength = 500; + + public Guid FlockId { get; private set; } + public DateOnly Date { get; private set; } + public BirdMovementType Type { get; private set; } + public int Quantity { get; private set; } + public string? Note { get; private set; } + + private BirdMovement() { } + + public static BirdMovement Create( + Guid id, Guid accountId, Guid flockId, + DateOnly date, BirdMovementType type, int quantity, string? note = null) + { + if (quantity == 0) + throw new ArgumentOutOfRangeException(nameof(quantity), "Movement quantity cannot be zero."); + if (quantity < 0 && type != BirdMovementType.Adjustment) + throw new ArgumentOutOfRangeException(nameof(quantity), + "Only adjustments may be negative; mortality and culls remove birds."); + if (date == default) + throw new ArgumentException("Movement date is required.", nameof(date)); + + var trimmed = string.IsNullOrWhiteSpace(note) ? null : note.Trim(); + if (trimmed?.Length > MaxNoteLength) + throw new ArgumentException($"Note cannot exceed {MaxNoteLength} characters.", nameof(note)); + + return new BirdMovement + { + Id = id, AccountId = accountId, + FlockId = flockId, + Date = date, + Type = type, + Quantity = quantity, + Note = trimmed, + }; + } +} + +public enum BirdMovementType { Mortality, Cull, Adjustment } diff --git a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs index 42eca805c..d0cfee3ce 100644 --- a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs +++ b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs @@ -14,6 +14,7 @@ public class AppDbContext(DbContextOptions options, TenantContext { public DbSet Accounts => Set(); public DbSet Flocks => Set(); + public DbSet BirdMovements => Set(); public DbSet DailyEntries => Set(); public DbSet DailyEntryGrades => Set(); public DbSet EggGrades => Set(); @@ -37,6 +38,7 @@ protected override void OnModelCreating(ModelBuilder builder) // Account.AccountId == Account.Id (self-reference), so the filter returns only the owning account. builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); + builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); builder.Entity().HasQueryFilter(e => e.AccountId == tenant.AccountId); diff --git a/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs b/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs index cb70ef09e..2a7ff8a1b 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs @@ -24,3 +24,26 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(e => new { e.AccountId, e.FarmId, e.HouseId }); } } + +public sealed class BirdMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.Id); + builder.Property(e => e.AccountId).IsRequired(); + builder.Property(e => e.FlockId).IsRequired(); + builder.Property(e => e.Type) + .HasConversion() + .HasMaxLength(16) + .IsRequired(); + builder.Property(e => e.Note).HasMaxLength(BirdMovement.MaxNoteLength); + + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.FlockId) + .OnDelete(DeleteBehavior.Restrict); + + // Ledger reads: per-flock browsing (newest first) and per-flock sums. + builder.HasIndex(e => new { e.AccountId, e.FlockId, e.Date }); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.Designer.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.Designer.cs new file mode 100644 index 000000000..3fc18b13d --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.Designer.cs @@ -0,0 +1,921 @@ +// +using System; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260716214949_AddBirdMovements")] + partial class AddBirdMovements + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DefaultCurrencyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultCurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CrackedEggs") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DirtyEggs") + .HasColumnType("integer"); + + b.Property("DiscardedEggs") + .HasColumnType("integer"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("MortalityCount") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TotalEggs") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") + .IsUnique() + .HasDatabaseName("IX_DailyEntries_NaturalKey"); + + b.ToTable("DailyEntries"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("DailyEntryId", "EggGradeId") + .IsUnique(); + + b.ToTable("DailyEntryGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("GradeType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsSaleable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EggGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("ProductionDate") + .HasColumnType("date"); + + b.Property("QuantityAvailable") + .HasColumnType("integer"); + + b.Property("QuantityProduced") + .HasColumnType("integer"); + + b.Property("RestrictedUntil") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") + .HasDatabaseName("IX_EggLots_Allocation"); + + b.ToTable("EggLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ArchivedOn") + .HasColumnType("date"); + + b.Property("Breed") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DepletedOn") + .HasColumnType("date"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("InitialCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlacementDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId"); + + b.ToTable("Flocks"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Name"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("OrderDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("AccountId", "ReferenceNumber") + .IsUnique(); + + b.ToTable("SalesOrders"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("SalesOrderId"); + + b.ToTable("SalesOrderItems"); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Jobs.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "RunAfter"); + + b.ToTable("durable_jobs", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Persistence.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EndpointHash", "IdempotencyKeyHash") + .IsUnique(); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany("Grades") + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "TotalAmount", b1 => + { + b1.Property("SalesOrderId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("TotalCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("TotalCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("TotalMinorUnits"); + + b1.HasKey("SalesOrderId"); + + b1.ToTable("SalesOrders"); + + b1.WithOwner() + .HasForeignKey("SalesOrderId"); + }); + + b.Navigation("TotalAmount") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany("Items") + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitPrice", b1 => + { + b1.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitPriceCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitPriceCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitPriceMinorUnits"); + + b1.HasKey("SalesOrderItemId"); + + b1.ToTable("SalesOrderItems"); + + b1.WithOwner() + .HasForeignKey("SalesOrderItemId"); + }); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.cs new file mode 100644 index 000000000..991511c3e --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716214949_AddBirdMovements.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddBirdMovements : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BirdMovements", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FlockId = table.Column(type: "uuid", nullable: false), + Date = table.Column(type: "date", nullable: false), + Type = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Quantity = table.Column(type: "integer", nullable: false), + Note = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + AccountId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BirdMovements", x => x.Id); + table.ForeignKey( + name: "FK_BirdMovements_Flocks_FlockId", + column: x => x.FlockId, + principalTable: "Flocks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_BirdMovements_AccountId_FlockId_Date", + table: "BirdMovements", + columns: new[] { "AccountId", "FlockId", "Date" }); + + migrationBuilder.CreateIndex( + name: "IX_BirdMovements_FlockId", + table: "BirdMovements", + column: "FlockId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BirdMovements"); + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 5f69e6b2b..b6a264a38 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -219,6 +219,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EggLots"); }); + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => { b.Property("Id") @@ -723,6 +759,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => { b.HasOne("Cluckwork.Domain.Sales.Customer", null) diff --git a/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs new file mode 100644 index 000000000..2e87a20e9 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs @@ -0,0 +1,43 @@ +namespace Cluckwork.Infrastructure.Repositories; + +using Cluckwork.Application.Features.Flocks; +using Cluckwork.Domain.Flocks; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +public sealed class BirdMovementRepository(AppDbContext db) : IBirdMovementRepository +{ + // Reads rely on the tenant query filter (AccountId == current tenant). + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + db.BirdMovements.FirstOrDefaultAsync(m => m.Id == id, ct); + + public async Task> ListByFlockAsync( + Guid flockId, int limit, int offset, CancellationToken ct = default) => + await db.BirdMovements + .AsNoTracking() + .Where(m => m.FlockId == flockId) + .OrderByDescending(m => m.Date).ThenByDescending(m => m.Id) + .Skip(offset) + .Take(limit) + .ToListAsync(ct); + + public async Task> RemovedByFlockAsync(CancellationToken ct = default) => + await db.BirdMovements + .AsNoTracking() + .GroupBy(m => m.FlockId) + .Select(g => new { FlockId = g.Key, Removed = g.Sum(m => (long)m.Quantity) }) + .ToDictionaryAsync(x => x.FlockId, x => x.Removed, ct); + + public Task RemovedForFlockAsync(Guid flockId, CancellationToken ct = default) => + db.BirdMovements + .AsNoTracking() + .Where(m => m.FlockId == flockId) + .SumAsync(m => (long)m.Quantity, ct); + + public async Task AddAsync(BirdMovement entity, CancellationToken ct = default) => + await db.BirdMovements.AddAsync(entity, ct); + + public void Update(BirdMovement entity) => db.BirdMovements.Update(entity); + + public void Remove(BirdMovement entity) => db.BirdMovements.Remove(entity); +} diff --git a/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs b/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs new file mode 100644 index 000000000..ab78ec14a --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs @@ -0,0 +1,176 @@ +namespace Cluckwork.Api.IntegrationTests; + +using System.Net; +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +// #54 — bird movement ledger: mortality generated at submit, manual +// culls/adjustments, current-count math, and lifecycle gating. +[Collection(IntegrationCollection.Name)] +public sealed class BirdMovementTests(CluckworkWebApplicationFactory factory) +{ + private sealed record IdDto(Guid Id); + private sealed record FlockDto( + Guid Id, Guid FarmId, Guid HouseId, string Name, + int InitialCount, long CurrentBirds, string Status); + private sealed record MovementDto( + Guid Id, Guid FlockId, DateOnly Date, string Type, int Quantity, string? Note); + + private async Task<(HttpClient Client, Guid AccountId, Guid FlockId, FlockDto Flock)> SetupAsync() + { + var email = $"u-{Guid.NewGuid():N}@test.local"; + var accountId = await factory.SeedAccountWithUserAsync(email); + var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email)); + var create = await client.PostWithKeyAsync( + "/api/v1/flocks", Guid.NewGuid().ToString(), + new { name = "Ledger flock", breed = "ISA", placementDate = "2026-01-01", initialCount = 100 }); + var id = (await create.Content.ReadFromJsonAsync())!.Id; + var flock = await client.GetFromJsonAsync($"/api/v1/flocks/{id}"); + return (client, accountId, id, flock!); + } + + private static object EntryBody(FlockDto f, DateOnly date, int mortality) => new + { + farmId = f.FarmId, + houseId = f.HouseId, + flockId = f.Id, + date, + totalEggs = 50, + crackedEggs = 0, + dirtyEggs = 0, + discardedEggs = 0, + mortalityCount = mortality, + }; + + [Fact] + public async Task Submit_GeneratesMortalityMovement_CountDrops() + { + var (client, _, flockId, flock) = await SetupAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + // Day with mortality: submit -> movement + count drop. + var e1 = await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today, 5)); + var id1 = (await e1.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{id1}/submit", Guid.NewGuid().ToString()); + + // Day without mortality: submit -> nothing added. + var e2 = await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today.AddDays(-1), 0)); + var id2 = (await e2.Content.ReadFromJsonAsync())!.Id; + await client.PostWithKeyAsync($"/api/v1/daily-entries/{id2}/submit", Guid.NewGuid().ToString()); + + var after = await client.GetFromJsonAsync($"/api/v1/flocks/{flockId}"); + Assert.Equal(95, after!.CurrentBirds); + + var movements = await client.GetFromJsonAsync>( + $"/api/v1/flocks/{flockId}/movements"); + var row = Assert.Single(movements!); + Assert.Equal("Mortality", row.Type); + Assert.Equal(5, row.Quantity); + Assert.Equal(today, row.Date); + + // Draft mortality is NOT a movement yet: record without submitting. + await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today.AddDays(-2), 7)); + var still = await client.GetFromJsonAsync($"/api/v1/flocks/{flockId}"); + Assert.Equal(95, still!.CurrentBirds); + } + + [Fact] + public async Task ManualCullAndAdjustment_CountMath() + { + var (client, _, flockId, _) = await SetupAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + var cull = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Cull", quantity = 10, note = "sold spent hens" }); + Assert.Equal(HttpStatusCode.Created, cull.StatusCode); + + // Negative adjustment adds birds back (miscount correction). + var adjust = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Adjustment", quantity = -3, note = "recount" }); + Assert.Equal(HttpStatusCode.Created, adjust.StatusCode); + + var flock = await client.GetFromJsonAsync($"/api/v1/flocks/{flockId}"); + Assert.Equal(93, flock!.CurrentBirds); // 100 - 10 + 3 + + // List view carries the same number via the grouped query. + var list = await client.GetFromJsonAsync>("/api/v1/flocks"); + Assert.Equal(93, list!.Single(f => f.Id == flockId).CurrentBirds); + } + + [Fact] + public async Task Movement_Validation_And_LifecycleGating() + { + var (client, _, flockId, _) = await SetupAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + // Manual Mortality would double-count with submitted entries -> 400. + var mortality = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Mortality", quantity = 1 }); + Assert.Equal(HttpStatusCode.BadRequest, mortality.StatusCode); + + // Zero quantity / negative cull -> 400. + var zero = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Cull", quantity = 0 }); + Assert.Equal(HttpStatusCode.BadRequest, zero.StatusCode); + var negativeCull = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Cull", quantity = -5 }); + Assert.Equal(HttpStatusCode.BadRequest, negativeCull.StatusCode); + + // Unknown flock -> 404 (write and read). + var unknown = await client.PostWithKeyAsync( + $"/api/v1/flocks/{Guid.NewGuid()}/movements", Guid.NewGuid().ToString(), + new { date = today, type = "Cull", quantity = 1 }); + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + var unknownList = await client.GetAsync($"/api/v1/flocks/{Guid.NewGuid()}/movements"); + Assert.Equal(HttpStatusCode.NotFound, unknownList.StatusCode); + + // Archived flock accepts nothing; depleted accepts backfill only. + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/deplete", Guid.NewGuid().ToString()); + var backfill = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today.AddDays(-1), type = "Cull", quantity = 1 }); + Assert.Equal(HttpStatusCode.Created, backfill.StatusCode); + var late = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today.AddDays(1), type = "Cull", quantity = 1 }); + Assert.Equal(HttpStatusCode.UnprocessableEntity, late.StatusCode); + + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/archive", Guid.NewGuid().ToString()); + var archived = await client.PostWithKeyAsync( + $"/api/v1/flocks/{flockId}/movements", Guid.NewGuid().ToString(), + new { date = today.AddDays(-1), type = "Cull", quantity = 1 }); + Assert.Equal(HttpStatusCode.UnprocessableEntity, archived.StatusCode); + } + + [Fact] + public async Task ParallelSubmits_ExactlyOneMortalityMovement() + { + // The submit race (entry Version token) must also protect the generated + // movement: the losing submit's movement rolls back with its lots. + var (client, accountId, flockId, flock) = await SetupAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + var create = await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today, 4)); + var entryId = (await create.Content.ReadFromJsonAsync())!.Id; + + var a = client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + var b = client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + await Task.WhenAll(a, b); + + var count = await factory.WithTenantScopeAsync(accountId, db => + db.BirdMovements.CountAsync(m => m.FlockId == flockId)); + Assert.Equal(1, count); + + var f = await client.GetFromJsonAsync($"/api/v1/flocks/{flockId}"); + Assert.Equal(96, f!.CurrentBirds); + } +} diff --git a/web/src/api/cluckwork.ts b/web/src/api/cluckwork.ts index 785ef852f..1e5d45df5 100644 --- a/web/src/api/cluckwork.ts +++ b/web/src/api/cluckwork.ts @@ -19,9 +19,19 @@ export interface Flock { breed: string; placementDate: string; initialCount: number; + currentBirds: number; status: string; } +export interface BirdMovement { + id: string; + flockId: string; + date: string; + type: string; + quantity: number; + note: string | null; +} + export interface GradeLine { eggGradeId: string; quantity: number; @@ -92,6 +102,20 @@ export const updateFlock = (id: string, body: { export const depleteFlock = (id: string, key?: string) => apiPost(`/flocks/${id}/deplete`, undefined, key); +export const listBirdMovements = (flockId: string, params?: { limit?: number; offset?: number }) => { + const q = new URLSearchParams(); + if (params?.limit) q.set("limit", String(params.limit)); + if (params?.offset) q.set("offset", String(params.offset)); + return apiGet(`/flocks/${flockId}/movements${q.size > 0 ? `?${q}` : ""}`); +}; + +export const recordBirdMovement = (flockId: string, body: { + date: string; + type: string; + quantity: number; + note?: string; +}, key?: string) => apiPost(`/flocks/${flockId}/movements`, body, key); + export const archiveFlock = (id: string, key?: string) => apiPost(`/flocks/${id}/archive`, undefined, key); diff --git a/web/src/routes/FlocksPage.tsx b/web/src/routes/FlocksPage.tsx index 45bda4d2c..19212730f 100644 --- a/web/src/routes/FlocksPage.tsx +++ b/web/src/routes/FlocksPage.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import { - archiveFlock, createFlock, depleteFlock, listFlocks, updateFlock, + archiveFlock, createFlock, depleteFlock, listBirdMovements, listFlocks, + recordBirdMovement, updateFlock, } from "../api/cluckwork"; -import type { Flock } from "../api/cluckwork"; +import type { BirdMovement, Flock } from "../api/cluckwork"; import { ApiError } from "../api/client"; import { todayIso } from "../lib/dates"; @@ -40,6 +41,14 @@ export function FlocksPage() { const [editPlaced, setEditPlaced] = useState(""); const [editCount, setEditCount] = useState(0); + // bird ledger (#54): one flock's movements open at a time + const [ledgerFlockId, setLedgerFlockId] = useState(null); + const [movements, setMovements] = useState(null); + const [mvDate, setMvDate] = useState(todayIso()); + const [mvType, setMvType] = useState("Cull"); + const [mvQty, setMvQty] = useState(1); + const [mvNote, setMvNote] = useState(""); + // Stable idempotency keys per logical mutation, rotated only after the full // action (write + refresh) succeeds — same contract as the other screens. const keys = useRef(new Map()); @@ -111,6 +120,38 @@ export function FlocksPage() { if (ok) setEditingId(null); } + async function openLedger(id: string) { + if (ledgerFlockId === id) { + setLedgerFlockId(null); + return; + } + setLedgerFlockId(id); + setMovements(null); + setMvDate(todayIso()); + try { + setMovements(await listBirdMovements(id, { limit: 50 })); + } catch { + setError("Could not load movements."); + } + } + + async function onRecordMovement(e: FormEvent) { + e.preventDefault(); + if (!ledgerFlockId) return; + const id = ledgerFlockId; + const ok = await run(`movement:${id}`, async (key) => { + await recordBirdMovement(id, { + date: mvDate, type: mvType, quantity: mvQty, + note: mvNote || undefined, + }, key); + setMovements(await listBirdMovements(id, { limit: 50 })); + }); + if (ok) { + setMvQty(1); + setMvNote(""); + } + } + if (error && flocks === null) { return

Flocks

{error}

; } @@ -162,7 +203,7 @@ export function FlocksPage() { NameBreedPlacedAge - Initial birdsStatus + BirdsStatus @@ -201,12 +242,20 @@ export function FlocksPage() { {f.breed} {f.placementDate} {ageWeeks(f.placementDate)} wk - {f.initialCount} + + {f.currentBirds} + {f.currentBirds !== f.initialCount && + / {f.initialCount}} + {f.status === "Active" ? "Active" : {f.status}} + {f.status === "Active" && ( @@ -229,6 +278,58 @@ export function FlocksPage() { )} + + {ledgerFlockId && ( +
+

+ Bird ledger — {flocks.find((f) => f.id === ledgerFlockId)?.name ?? ""} +

+

+ Mortality rows come from submitted daily entries. Record culls here; + use a negative adjustment to correct a miscount. +

+ +
+ + + + setMvNote(e.target.value)} /> + +
+ + {movements === null ? ( +

Loading…

+ ) : movements.length === 0 ? ( +

No movements yet — the flock is at its initial count.

+ ) : ( + + + + + + {movements.map((m) => ( + + + + + + + ))} + +
DateTypeBirdsNote
{m.date}{m.type}{m.quantity > 0 ? `−${m.quantity}` : `+${-m.quantity}`}{m.note ?? "—"}
+ )} +
+ )} ); } From fc46fdddef323744abf93c8544dc93df928060f8 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Thu, 16 Jul 2026 15:02:44 -0700 Subject: [PATCH 2/2] Address codex + pi review feedback on bird movements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backfill migration: entries submitted before this feature carried mortality that the new ledger-derived CurrentBirds would silently forget. The EntryRef migration inserts Mortality rows for existing non-Draft entries; the NOT EXISTS guard covers both the new back-reference and same-flock/date rows created between the two migrations. (codex P1) - BirdMovement.DailyEntryId back-reference on generated rows (FK, nullable) — the reconciliation hook the future manager-adjust flow needs to find the ledger side of a corrected entry. (pi Medium) - Submit now applies the same lifecycle gate as recording: an archived flock's leftover draft can no longer mint lots + a mortality row; depleted-flock backfill drafts still submit when dated on/before DepletedOn. (pi Low) - Repository Update/Remove throw NotSupportedException — the append-only invariant is enforced at the seam, not just by convention. (pi Low) - FlocksPage: ledger fetches are guarded by the currently-open flock id, so a slow response for flock A can't render under flock B's heading or clobber a post-write refresh. Cull quantity input gets min=1. (codex P2, pi Low) 120 tests green; backfill verified against the dev DB: a pre-feature submitted entry gained a back-referenced Mortality row and an existing movement was not duplicated. --- .../SubmitDailyEntryHandler.cs | 13 +- src/Cluckwork.Domain/Flocks/BirdMovement.cs | 8 +- .../Configurations/FlockConfiguration.cs | 5 + ...irdMovementEntryRefAndBackfill.Designer.cs | 931 ++++++++++++++++++ ...5914_AddBirdMovementEntryRefAndBackfill.cs | 71 ++ .../Migrations/AppDbContextModelSnapshot.cs | 10 + .../Repositories/BirdMovementRepository.cs | 8 +- .../BirdMovementTests.cs | 37 +- web/src/routes/FlocksPage.tsx | 15 +- 9 files changed, 1090 insertions(+), 8 deletions(-) create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.Designer.cs create mode 100644 src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.cs diff --git a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs index 7f2cc456e..cb70e1aff 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs @@ -18,6 +18,7 @@ public sealed class SubmitDailyEntryHandler( IDailyEntryRepository entries, IEggLotRepository eggLots, IBirdMovementRepository birdMovements, + IFlockRepository flocks, IUnitOfWork unitOfWork) { public async Task> HandleAsync( @@ -29,6 +30,15 @@ public async Task> HandleAsync( return Result.Failure( Error.NotFound(nameof(DailyEntry), dailyEntryId)); + // Same lifecycle gate as recording (#47/#54): a draft can still be + // submitted after depletion when its date is on/before DepletedOn + // (late backfill), but never for an archived flock. + var flock = await flocks.GetByIdAsync(entry.FlockId, ct); + if (flock is not null && !flock.CanRecordProductionOn(entry.Date)) + return Result.Failure(Error.Validation( + "DailyEntry.FlockNotActive", + $"Flock '{flock.Name}' is {flock.Status.ToString().ToLowerInvariant()} — this entry can no longer be submitted.")); + var submit = entry.Submit(); if (submit.IsFailure) return Result.Failure(submit.Error); @@ -50,7 +60,8 @@ public async Task> HandleAsync( await birdMovements.AddAsync(BirdMovement.Create( Guid.NewGuid(), accountId, entry.FlockId, entry.Date, BirdMovementType.Mortality, entry.MortalityCount, - note: "Daily entry mortality"), ct); + note: "Daily entry mortality", + dailyEntryId: entry.Id), ct); } // A concurrent submit that loses the Version race throws diff --git a/src/Cluckwork.Domain/Flocks/BirdMovement.cs b/src/Cluckwork.Domain/Flocks/BirdMovement.cs index cde1b2d6b..2e28d8920 100644 --- a/src/Cluckwork.Domain/Flocks/BirdMovement.cs +++ b/src/Cluckwork.Domain/Flocks/BirdMovement.cs @@ -16,12 +16,17 @@ public sealed class BirdMovement : AggregateRoot public BirdMovementType Type { get; private set; } public int Quantity { get; private set; } public string? Note { get; private set; } + // Set on generated Mortality rows: the daily entry that produced this row, + // so a future reconciliation flow (manager adjust / void-and-resubmit) can + // find and correct the ledger side. Null for manual movements. + public Guid? DailyEntryId { get; private set; } private BirdMovement() { } public static BirdMovement Create( Guid id, Guid accountId, Guid flockId, - DateOnly date, BirdMovementType type, int quantity, string? note = null) + DateOnly date, BirdMovementType type, int quantity, string? note = null, + Guid? dailyEntryId = null) { if (quantity == 0) throw new ArgumentOutOfRangeException(nameof(quantity), "Movement quantity cannot be zero."); @@ -43,6 +48,7 @@ public static BirdMovement Create( Type = type, Quantity = quantity, Note = trimmed, + DailyEntryId = dailyEntryId, }; } } diff --git a/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs b/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs index 2a7ff8a1b..b4b4db229 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Configurations/FlockConfiguration.cs @@ -43,6 +43,11 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(e => e.FlockId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne() + .WithMany() + .HasForeignKey(e => e.DailyEntryId) + .OnDelete(DeleteBehavior.Restrict); + // Ledger reads: per-flock browsing (newest first) and per-flock sums. builder.HasIndex(e => new { e.AccountId, e.FlockId, e.Date }); } diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.Designer.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.Designer.cs new file mode 100644 index 000000000..a0b6166e6 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.Designer.cs @@ -0,0 +1,931 @@ +// +using System; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260716215914_AddBirdMovementEntryRefAndBackfill")] + partial class AddBirdMovementEntryRefAndBackfill + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DefaultCurrencyCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("DefaultCurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CrackedEggs") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DirtyEggs") + .HasColumnType("integer"); + + b.Property("DiscardedEggs") + .HasColumnType("integer"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("MortalityCount") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TotalEggs") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") + .IsUnique() + .HasDatabaseName("IX_DailyEntries_NaturalKey"); + + b.ToTable("DailyEntries"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("DailyEntryId", "EggGradeId") + .IsUnique(); + + b.ToTable("DailyEntryGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("GradeType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsSaleable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("EggGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("ProductionDate") + .HasColumnType("date"); + + b.Property("QuantityAvailable") + .HasColumnType("integer"); + + b.Property("QuantityProduced") + .HasColumnType("integer"); + + b.Property("RestrictedUntil") + .HasColumnType("date"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") + .HasDatabaseName("IX_EggLots_Allocation"); + + b.ToTable("EggLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ArchivedOn") + .HasColumnType("date"); + + b.Property("Breed") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DepletedOn") + .HasColumnType("date"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("InitialCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PlacementDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId"); + + b.ToTable("Flocks"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Name"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("OrderDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("AccountId", "ReferenceNumber") + .IsUnique(); + + b.ToTable("SalesOrders"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("SalesOrderId"); + + b.ToTable("SalesOrderItems"); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Jobs.DurableJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "RunAfter"); + + b.ToTable("durable_jobs", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Persistence.IdempotencyRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IdempotencyKeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResponseBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EndpointHash", "IdempotencyKeyHash") + .IsUnique(); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany("Grades") + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "TotalAmount", b1 => + { + b1.Property("SalesOrderId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("TotalCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("TotalCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("TotalMinorUnits"); + + b1.HasKey("SalesOrderId"); + + b1.ToTable("SalesOrders"); + + b1.WithOwner() + .HasForeignKey("SalesOrderId"); + }); + + b.Navigation("TotalAmount") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany("Items") + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitPrice", b1 => + { + b1.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitPriceCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitPriceCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitPriceMinorUnits"); + + b1.HasKey("SalesOrderItemId"); + + b1.ToTable("SalesOrderItems"); + + b1.WithOwner() + .HasForeignKey("SalesOrderItemId"); + }); + + b.Navigation("UnitPrice") + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Navigation("Grades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.cs new file mode 100644 index 000000000..781f5b124 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260716215914_AddBirdMovementEntryRefAndBackfill.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddBirdMovementEntryRefAndBackfill : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DailyEntryId", + table: "BirdMovements", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_BirdMovements_DailyEntryId", + table: "BirdMovements", + column: "DailyEntryId"); + + migrationBuilder.AddForeignKey( + name: "FK_BirdMovements_DailyEntries_DailyEntryId", + table: "BirdMovements", + column: "DailyEntryId", + principalTable: "DailyEntries", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + // Backfill: entries submitted before this feature already recorded + // mortality, but the submit-time side effect didn't exist — without + // these rows every pre-existing flock's CurrentBirds silently + // forgets its historical deaths. Idempotent via the NOT EXISTS on + // the back-reference. + migrationBuilder.Sql( + """ + INSERT INTO "BirdMovements" + ("Id", "AccountId", "FlockId", "Date", "Type", "Quantity", "Note", "DailyEntryId") + SELECT gen_random_uuid(), e."AccountId", e."FlockId", e."Date", + 'Mortality', e."MortalityCount", 'Daily entry mortality (backfilled)', e."Id" + FROM "DailyEntries" e + WHERE e."Status" <> 'Draft' + AND e."MortalityCount" > 0 + AND NOT EXISTS ( + SELECT 1 FROM "BirdMovements" m + WHERE m."DailyEntryId" = e."Id" + OR (m."FlockId" = e."FlockId" AND m."Date" = e."Date" + AND m."Type" = 'Mortality')); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BirdMovements_DailyEntries_DailyEntryId", + table: "BirdMovements"); + + migrationBuilder.DropIndex( + name: "IX_BirdMovements_DailyEntryId", + table: "BirdMovements"); + + migrationBuilder.DropColumn( + name: "DailyEntryId", + table: "BirdMovements"); + } + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index b6a264a38..28d41769f 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -228,6 +228,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AccountId") .HasColumnType("uuid"); + b.Property("DailyEntryId") + .HasColumnType("uuid"); + b.Property("Date") .HasColumnType("date"); @@ -248,6 +251,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("DailyEntryId"); + b.HasIndex("FlockId"); b.HasIndex("AccountId", "FlockId", "Date"); @@ -761,6 +766,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => { + b.HasOne("Cluckwork.Domain.Eggs.DailyEntry", null) + .WithMany() + .HasForeignKey("DailyEntryId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) .WithMany() .HasForeignKey("FlockId") diff --git a/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs index 2e87a20e9..6c5d7c598 100644 --- a/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs @@ -37,7 +37,11 @@ public Task RemovedForFlockAsync(Guid flockId, CancellationToken ct = defa public async Task AddAsync(BirdMovement entity, CancellationToken ct = default) => await db.BirdMovements.AddAsync(entity, ct); - public void Update(BirdMovement entity) => db.BirdMovements.Update(entity); + // The ledger is append-only: corrections are new Adjustment rows, never + // edits or deletes — enforced here so no handler can drift. + public void Update(BirdMovement entity) => + throw new NotSupportedException("Bird movements are append-only; record an Adjustment instead."); - public void Remove(BirdMovement entity) => db.BirdMovements.Remove(entity); + public void Remove(BirdMovement entity) => + throw new NotSupportedException("Bird movements are append-only; record an Adjustment instead."); } diff --git a/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs b/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs index ab78ec14a..4bf1cb127 100644 --- a/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/BirdMovementTests.cs @@ -45,7 +45,7 @@ private sealed record MovementDto( [Fact] public async Task Submit_GeneratesMortalityMovement_CountDrops() { - var (client, _, flockId, flock) = await SetupAsync(); + var (client, accountId, flockId, flock) = await SetupAsync(); var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); // Day with mortality: submit -> movement + count drop. @@ -70,6 +70,11 @@ public async Task Submit_GeneratesMortalityMovement_CountDrops() Assert.Equal(5, row.Quantity); Assert.Equal(today, row.Date); + // Generated rows carry the originating entry id (reconciliation hook). + var backRef = await factory.WithTenantScopeAsync(accountId, db => + db.BirdMovements.SingleAsync(m => m.FlockId == flockId)); + Assert.Equal(id1, backRef.DailyEntryId); + // Draft mortality is NOT a movement yet: record without submitting. await client.PostWithKeyAsync( "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today.AddDays(-2), 7)); @@ -150,6 +155,36 @@ public async Task Movement_Validation_And_LifecycleGating() Assert.Equal(HttpStatusCode.UnprocessableEntity, archived.StatusCode); } + [Fact] + public async Task Submit_OfArchivedFlockDraft_Rejected() + { + // Recording gates on lifecycle; submit must too, or an archived flock's + // leftover draft could still mint lots + a mortality row. + var (client, _, flockId, flock) = await SetupAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + + var create = await client.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), EntryBody(flock, today, 3)); + var entryId = (await create.Content.ReadFromJsonAsync())!.Id; + + await client.PostWithKeyAsync($"/api/v1/flocks/{flockId}/archive", Guid.NewGuid().ToString()); + + var submit = await client.PostWithKeyAsync( + $"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.UnprocessableEntity, submit.StatusCode); + + // Depleted + backfill date: submit still allowed (late final-day entry). + var (client2, _, flockId2, flock2) = await SetupAsync(); + var create2 = await client2.PostWithKeyAsync( + "/api/v1/daily-entries", Guid.NewGuid().ToString(), + EntryBody(flock2, today.AddDays(-1), 2)); + var entryId2 = (await create2.Content.ReadFromJsonAsync())!.Id; + await client2.PostWithKeyAsync($"/api/v1/flocks/{flockId2}/deplete", Guid.NewGuid().ToString()); + var submit2 = await client2.PostWithKeyAsync( + $"/api/v1/daily-entries/{entryId2}/submit", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.OK, submit2.StatusCode); + } + [Fact] public async Task ParallelSubmits_ExactlyOneMortalityMovement() { diff --git a/web/src/routes/FlocksPage.tsx b/web/src/routes/FlocksPage.tsx index 19212730f..dde657029 100644 --- a/web/src/routes/FlocksPage.tsx +++ b/web/src/routes/FlocksPage.tsx @@ -120,18 +120,25 @@ export function FlocksPage() { if (ok) setEditingId(null); } + // Guards the async fetch: only the ledger currently open may write state, + // so a slow response for flock A can't render under flock B's heading. + const ledgerRequest = useRef(null); + async function openLedger(id: string) { if (ledgerFlockId === id) { setLedgerFlockId(null); + ledgerRequest.current = null; return; } setLedgerFlockId(id); setMovements(null); setMvDate(todayIso()); + ledgerRequest.current = id; try { - setMovements(await listBirdMovements(id, { limit: 50 })); + const rows = await listBirdMovements(id, { limit: 50 }); + if (ledgerRequest.current === id) setMovements(rows); } catch { - setError("Could not load movements."); + if (ledgerRequest.current === id) setError("Could not load movements."); } } @@ -144,7 +151,8 @@ export function FlocksPage() { date: mvDate, type: mvType, quantity: mvQty, note: mvNote || undefined, }, key); - setMovements(await listBirdMovements(id, { limit: 50 })); + const rows = await listBirdMovements(id, { limit: 50 }); + if (ledgerRequest.current === id) setMovements(rows); }); if (ok) { setMvQty(1); @@ -300,6 +308,7 @@ export function FlocksPage() {