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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions src/Cluckwork.Api/Endpoints/Flocks/FlockEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -69,7 +78,7 @@ private static async Task<IResult> CreateFlock(
}

private static async Task<IResult> 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();
Expand All @@ -78,15 +87,59 @@ private static async Task<IResult> 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<IResult> 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<IResult> RecordMovement(
Guid id,
RecordBirdMovementRequest request,
RecordBirdMovementHandler handler,
IValidator<RecordBirdMovementCommand> 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<IResult> 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<IResult> UpdateFlock(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
4 changes: 4 additions & 0 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -131,6 +132,7 @@
builder.Services.AddScoped<IEggGradeRepository, EggGradeRepository>();
builder.Services.AddScoped<ISalesOrderRepository, SalesOrderRepository>();
builder.Services.AddScoped<IFlockRepository, FlockRepository>();
builder.Services.AddScoped<IBirdMovementRepository, BirdMovementRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IAccountRepository, AccountRepository>();

Expand All @@ -144,6 +146,7 @@
builder.Services.AddScoped<IValidator<CreateEggGradeCommand>, CreateEggGradeValidator>();
builder.Services.AddScoped<IValidator<UpdateEggGradeCommand>, UpdateEggGradeValidator>();
builder.Services.AddScoped<IValidator<UpdateFlockCommand>, UpdateFlockValidator>();
builder.Services.AddScoped<IValidator<RecordBirdMovementCommand>, RecordBirdMovementValidator>();

// --- Handlers (direct — no mediator, tech spec §2.1) ---
builder.Services.AddScoped<RecordDailyEntryHandler>();
Expand All @@ -162,6 +165,7 @@
builder.Services.AddScoped<SetEggGradeActiveHandler>();
builder.Services.AddScoped<UpdateFlockHandler>();
builder.Services.AddScoped<ArchiveFlockHandler>();
builder.Services.AddScoped<RecordBirdMovementHandler>();

// --- Startup seed (single-farm MVP) ---
builder.Services.Configure<SeedOptions>(builder.Configuration.GetSection(SeedOptions.SectionName));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ 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,
IFlockRepository flocks,
IUnitOfWork unitOfWork)
{
public async Task<Result<SubmitDailyEntryResponse>> HandleAsync(
Expand All @@ -25,6 +30,15 @@ public async Task<Result<SubmitDailyEntryResponse>> HandleAsync(
return Result.Failure<SubmitDailyEntryResponse>(
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<SubmitDailyEntryResponse>(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<SubmitDailyEntryResponse>(submit.Error);
Expand All @@ -39,6 +53,17 @@ public async Task<Result<SubmitDailyEntryResponse>> 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",
dailyEntryId: entry.Id), 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Cluckwork.Application.Features.Flocks;

using Cluckwork.Application.Common;
using Cluckwork.Domain.Flocks;

public interface IBirdMovementRepository : IRepository<BirdMovement, Guid>
{
// Newest first (date, then id) — ledger browsing.
Task<IReadOnlyList<BirdMovement>> 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<Dictionary<Guid, long>> RemovedByFlockAsync(CancellationToken ct = default);

Task<long> RemovedForFlockAsync(Guid flockId, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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<Result<Guid>> HandleAsync(
RecordBirdMovementCommand command, Guid accountId, CancellationToken ct)
{
var flock = await flocks.GetByIdAsync(command.FlockId, ct);
if (flock is null)
return Result.Failure<Guid>(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<Guid>(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<BirdMovementType>(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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement;

using Cluckwork.Domain.Flocks;
using FluentValidation;

public sealed class RecordBirdMovementValidator : AbstractValidator<RecordBirdMovementCommand>
{
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.");
}
}
56 changes: 56 additions & 0 deletions src/Cluckwork.Domain/Flocks/BirdMovement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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<Guid>
{
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; }
// 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,
Guid? dailyEntryId = 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,
DailyEntryId = dailyEntryId,
};
}
}

public enum BirdMovementType { Mortality, Cull, Adjustment }
2 changes: 2 additions & 0 deletions src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options, TenantContext
{
public DbSet<Account> Accounts => Set<Account>();
public DbSet<Flock> Flocks => Set<Flock>();
public DbSet<BirdMovement> BirdMovements => Set<BirdMovement>();
public DbSet<DailyEntry> DailyEntries => Set<DailyEntry>();
public DbSet<DailyEntryGrade> DailyEntryGrades => Set<DailyEntryGrade>();
public DbSet<EggGrade> EggGrades => Set<EggGrade>();
Expand All @@ -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<Account>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
builder.Entity<Flock>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
builder.Entity<BirdMovement>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
builder.Entity<DailyEntry>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
builder.Entity<DailyEntryGrade>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
builder.Entity<EggGrade>().HasQueryFilter(e => e.AccountId == tenant.AccountId);
Expand Down
Loading
Loading