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
16 changes: 16 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@ from the account onto each order (JPY has 0 decimals, USD 2 — the snapshot
records that too). Totals are recalculated from lines on every mutation,
never incrementally patched.

**Hen-day % (#91)** — eggs collected ÷ hen-days × 100 (spec §19.3). A
hen-day is one bird alive for one day; the day's bird count comes from the
bird ledger (placements + movements). The production report shows it per
day and for the whole period (period eggs ÷ period hen-days — not an
average of daily percentages).

**Production report (#91)** — per-day official production over a range
(Draft entries aren't submitted, Voided ones vacated their day — neither
counts): eggs, losses, sellable, deaths, hen-day %, period totals, grade
breakdown. Open to workers — it is their own recorded work.

**Profit, basic (#91)** — confirmed order revenue − recorded expenses for
the range, both operands shown. Deliberately no COGS or inventory
valuation; those belong to a later accounting slice. Admin-only, as are
the sales and expense summaries.

**Payment (#89)** — money in against a **confirmed** sales order (spec
§10.11): date, amount in minor units, method (cash / check / card / bank
transfer / mobile payment / other), optional reference and note. The
Expand Down
98 changes: 98 additions & 0 deletions src/Cluckwork.Api/Endpoints/Reports/ReportEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
namespace Cluckwork.Api.Endpoints.Reports;

using Cluckwork.Application.Features.Reports;
using Cluckwork.Infrastructure.Persistence;

// #91 — core reports. Production is open to every signed-in user (workers
// record it, workers may read it); the money summaries are AdminOnly, reads
// included (#87/#89 split).
public static class ReportEndpoints
{
// Guard: a report over decades of days would build a giant payload row by
// row. One year covers every report the phase needs.
private const int MaxRangeDays = 366;

public static RouteGroupBuilder MapReportEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/production", Production)
.WithName("ProductionReport")
.WithSummary("Per-day production across the range: eggs, losses, sellable, deaths, hen-day % (spec §19.3), plus period totals and grade breakdown.");

group.MapGet("/sales", Sales)
.WithName("SalesSummaryReport")
.WithSummary("Confirmed orders in the range: count, revenue, settled payments, outstanding; voided count.")
.RequireAuthorization(AuthPolicies.AdminOnly);

group.MapGet("/expenses", Expenses)
.WithName("ExpenseSummaryReport")
.WithSummary("Per-category expense totals and grand total for the range.")
.RequireAuthorization(AuthPolicies.AdminOnly);

group.MapGet("/profit", Profit)
.WithName("ProfitReport")
.WithSummary("Basic profit for the range: confirmed revenue minus recorded expenses (no COGS).")
.RequireAuthorization(AuthPolicies.AdminOnly);

return group;
}

private static IResult? ValidateRange(DateOnly? from, DateOnly? to, out DateOnly f, out DateOnly t)
{
// Default: the last 7 days (inclusive), UTC until #35.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
f = from ?? today.AddDays(-6);
t = to ?? today;
if (f > t)
return Results.Problem("'from' must not be after 'to'.",
statusCode: 400, title: "Report.InvalidRange");
if (t.DayNumber - f.DayNumber >= MaxRangeDays)
return Results.Problem($"Range cannot exceed {MaxRangeDays} days.",
statusCode: 400, title: "Report.RangeTooLarge");
// Reports describe the past; also guards the DateOnly.MaxValue walk
// (codex review of #92).
if (t > today)
return Results.Problem("'to' cannot be in the future.",
statusCode: 400, title: "Report.FutureRange");
return null;
}

private static async Task<IResult> Production(
IReportQueries reports, TenantContext tenant, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var bad = ValidateRange(from, to, out var f, out var t);
if (bad is not null) return bad;
return Results.Ok(await reports.GetProductionAsync(f, t, ct));
}

private static async Task<IResult> Sales(
IReportQueries reports, TenantContext tenant, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var bad = ValidateRange(from, to, out var f, out var t);
if (bad is not null) return bad;
return Results.Ok(await reports.GetSalesAsync(f, t, ct));
}

private static async Task<IResult> Expenses(
IReportQueries reports, TenantContext tenant, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var bad = ValidateRange(from, to, out var f, out var t);
if (bad is not null) return bad;
return Results.Ok(await reports.GetExpensesAsync(f, t, ct));
}

private static async Task<IResult> Profit(
IReportQueries reports, TenantContext tenant, CancellationToken ct,
DateOnly? from = null, DateOnly? to = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();
var bad = ValidateRange(from, to, out var f, out var t);
if (bad is not null) return bad;
return Results.Ok(await reports.GetProfitAsync(f, t, ct));
}
}
9 changes: 9 additions & 0 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Cluckwork.Api.Endpoints.EggGrades;
using Cluckwork.Api.Endpoints.Expenses;
using Cluckwork.Api.Endpoints.Flocks;
using Cluckwork.Api.Endpoints.Reports;
using Cluckwork.Api.Endpoints.Inventory;
using Cluckwork.Api.Endpoints.Sales;
using Cluckwork.Api.Endpoints.Water;
Expand Down Expand Up @@ -170,6 +171,7 @@
builder.Services.AddScoped<ISalesOrderRepository, SalesOrderRepository>();
builder.Services.AddScoped<ISalesOrderAllocationRepository, SalesOrderAllocationRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<Cluckwork.Application.Features.Reports.IReportQueries, ReportQueries>();
builder.Services.AddScoped<IInventoryItemRepository, InventoryItemRepository>();
builder.Services.AddScoped<IInventoryLotRepository, InventoryLotRepository>();
builder.Services.AddScoped<IInventoryMovementRepository, InventoryMovementRepository>();
Expand Down Expand Up @@ -387,6 +389,13 @@
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapPaymentEndpoints();

// Reports (#91): production is open; the money routes carry their own
// AdminOnly inside the group.
app.MapGroup("/api/v1/reports")
.WithTags("Reports")
.RequireAuthorization()
.MapReportEndpoints();

app.MapGroup("/api/v1/users")
.WithTags("Users")
.RequireAuthorization(AuthPolicies.AdminOnly)
Expand Down
47 changes: 47 additions & 0 deletions src/Cluckwork.Application/Features/Reports/IReportQueries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Cluckwork.Application.Features.Reports;

// Read-only report aggregates (#91, spec §19). Every number is computed
// server-side in one place — pages/cards never re-derive or sum rows.
public interface IReportQueries
{
// Production (spec §19.3 hen-day %): official entries only — Draft isn't
// submitted yet, Voided vacated its day (#82).
Task<ProductionReport> GetProductionAsync(DateOnly from, DateOnly to, CancellationToken ct = default);

// Money summaries — the callers gate these behind AdminOnly.
Task<SalesSummary> GetSalesAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
Task<ExpenseSummary> GetExpensesAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
Task<ProfitReport> GetProfitAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
}

public sealed record ProductionDay(
DateOnly Date, int TotalEggs, int Cracked, int Dirty, int Discarded,
int Sellable, int Deaths, long HenDays, decimal? HenDayPct);

public sealed record GradeTotal(Guid EggGradeId, string Name, int Quantity);

public sealed record ProductionReport(
IReadOnlyList<ProductionDay> Days,
int TotalEggs, int TotalSellable, int TotalDeaths,
long TotalHenDays, decimal? PeriodHenDayPct,
IReadOnlyList<GradeTotal> GradeTotals);

// About the period's ORDERS (order date in range): revenue is their confirmed
// totals; paid is settled payments attached to THOSE orders whenever they were
// received — so outstanding = revenue − paid is the period's open AR.
public sealed record SalesSummary(
int ConfirmedCount, long RevenueMinorUnits, long PaidMinorUnits,
long OutstandingMinorUnits, int VoidedCount,
string CurrencyCode, int CurrencyMinorUnit);

public sealed record ExpenseCategoryTotal(Guid ExpenseCategoryId, string Name, long TotalMinorUnits);

public sealed record ExpenseSummary(
IReadOnlyList<ExpenseCategoryTotal> Categories, long GrandTotalMinorUnits,
string CurrencyCode, int CurrencyMinorUnit);

// "Basic" deliberately: confirmed revenue − recorded expenses, no COGS or
// inventory valuation. Both operands shipped so the figure is auditable.
public sealed record ProfitReport(
long RevenueMinorUnits, long ExpensesMinorUnits, long ProfitMinorUnits,
string CurrencyCode, int CurrencyMinorUnit);
183 changes: 183 additions & 0 deletions src/Cluckwork.Infrastructure/Repositories/ReportQueries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
namespace Cluckwork.Infrastructure.Repositories;

using Cluckwork.Application.Features.Reports;
using Cluckwork.Domain.Eggs;
using Cluckwork.Domain.Sales;
using Cluckwork.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

public sealed class ReportQueries(AppDbContext db) : IReportQueries
{
// Official entries only: Draft isn't submitted, Voided vacated its day (#82).
private static readonly DailyEntryStatus[] OfficialStatuses =
[DailyEntryStatus.Submitted, DailyEntryStatus.Locked, DailyEntryStatus.ManagerAdjusted];

public async Task<ProductionReport> GetProductionAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
{
var perDay = await db.DailyEntries
.Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status))
.GroupBy(e => e.Date)
.Select(g => new
{
Date = g.Key,
Total = g.Sum(e => e.TotalEggs),
Cracked = g.Sum(e => e.CrackedEggs),
Dirty = g.Sum(e => e.DirtyEggs),
Discarded = g.Sum(e => e.DiscardedEggs),
Deaths = g.Sum(e => e.MortalityCount),
})
.ToDictionaryAsync(x => x.Date, ct);

// Period grade totals (per-day × per-grade would bloat the payload).
var gradeTotals = await db.DailyEntryGrades
.Where(g => db.DailyEntries.Any(e =>
e.Id == g.DailyEntryId && e.Date >= from && e.Date <= to
&& OfficialStatuses.Contains(e.Status)))
.GroupBy(g => g.EggGradeId)
.Select(g => new { EggGradeId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToListAsync(ct);
var gradeNames = await db.EggGrades
.Where(g => gradeTotals.Select(t => t.EggGradeId).Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, ct);

// Hen-days (spec §19.3): the farm's bird count on each day, summed over
// the range, per flock so lifecycle can terminate a flock's
// contribution. A flock counts on day D only while placed and not yet
// depleted/archived (a depletion writes NO removal movement — without
// the terminator its remaining birds would count forever; codex review
// of #92). Start-of-day convention: the day's deaths do not shrink that
// day's denominator (industry hen-day practice), so a flock's count on
// D is initial − removals BEFORE D.
var flocks = await db.Flocks
.Select(f => new
{
f.Id,
f.PlacementDate,
f.InitialCount,
f.DepletedOn,
f.ArchivedOn,
})
.ToListAsync(ct);
var removalsByFlock = (await db.BirdMovements
.GroupBy(m => new { m.FlockId, m.Date })
.Select(g => new { g.Key.FlockId, g.Key.Date, Quantity = g.Sum(m => (long)m.Quantity) })
.ToListAsync(ct))
.GroupBy(x => x.FlockId)
.ToDictionary(g => g.Key, g => g.OrderBy(x => x.Date).ToList());

var days = new List<ProductionDay>();
// Per flock: birds at start of `from` = initial − removals strictly
// before `from`; then walk, applying each day's removals AFTER counting.
var flockCounts = flocks.ToDictionary(
f => f.Id,
f => f.InitialCount
- (removalsByFlock.GetValueOrDefault(f.Id)?.Where(r => r.Date < from).Sum(r => r.Quantity) ?? 0L));
for (var d = from; d <= to; d = d.AddDays(1))
{
long henDays = 0;
foreach (var f in flocks)
{
var ended = (f.DepletedOn is { } dep && d > dep)
|| (f.ArchivedOn is { } arc && d > arc);
if (f.PlacementDate <= d && !ended)
henDays += Math.Max(flockCounts[f.Id], 0);
var todaysRemovals = removalsByFlock.GetValueOrDefault(f.Id)
?.Where(r => r.Date == d).Sum(r => r.Quantity) ?? 0L;
flockCounts[f.Id] -= todaysRemovals;
}

var row = perDay.GetValueOrDefault(d);
var total = row?.Total ?? 0;
var sellable = total - (row?.Cracked ?? 0) - (row?.Dirty ?? 0) - (row?.Discarded ?? 0);
days.Add(new ProductionDay(
d, total, row?.Cracked ?? 0, row?.Dirty ?? 0, row?.Discarded ?? 0,
sellable, row?.Deaths ?? 0, henDays,
henDays > 0 ? Math.Round(total * 100m / henDays, 1) : null));
if (d == DateOnly.MaxValue) break; // AddDays would overflow
}

var totalEggs = days.Sum(x => x.TotalEggs);
var totalHenDays = days.Sum(x => x.HenDays);
return new ProductionReport(
days,
totalEggs,
days.Sum(x => x.Sellable),
days.Sum(x => x.Deaths),
totalHenDays,
totalHenDays > 0 ? Math.Round(totalEggs * 100m / totalHenDays, 1) : null,
gradeTotals
.Select(t => new GradeTotal(
t.EggGradeId, gradeNames.GetValueOrDefault(t.EggGradeId, "?"), t.Quantity))
.OrderByDescending(t => t.Quantity)
.ToList());
}

public async Task<SalesSummary> GetSalesAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
{
var confirmed = await db.SalesOrders
.Where(o => o.Status == SalesOrderStatus.Confirmed
&& o.OrderDate >= from && o.OrderDate <= to)
.Select(o => new { o.Id, Total = o.TotalAmount.MinorUnits })
.ToListAsync(ct);

var orderIds = confirmed.Select(o => o.Id).ToList();
var paid = orderIds.Count == 0 ? 0L : await db.Payments
.Where(p => !p.Voided && orderIds.Contains(p.SalesOrderId))
.SumAsync(p => p.AmountMinorUnits, ct);

var voidedCount = await db.SalesOrders
.CountAsync(o => o.Status == SalesOrderStatus.Voided
&& o.OrderDate >= from && o.OrderDate <= to, ct);

var revenue = confirmed.Sum(o => o.Total);
var (code, minor) = await AccountCurrencyAsync(ct);
return new SalesSummary(
confirmed.Count, revenue, paid, revenue - paid, voidedCount, code, minor);
}

public async Task<ExpenseSummary> GetExpensesAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
{
var perCategory = await db.Expenses
.Where(e => e.Date >= from && e.Date <= to)
.GroupBy(e => e.ExpenseCategoryId)
.Select(g => new { CategoryId = g.Key, Total = g.Sum(e => e.AmountMinorUnits) })
.ToListAsync(ct);
var names = await db.ExpenseCategories
.Where(c => perCategory.Select(p => p.CategoryId).Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.Name, ct);

var (code, minor) = await AccountCurrencyAsync(ct);
return new ExpenseSummary(
perCategory
.Select(p => new ExpenseCategoryTotal(
p.CategoryId, names.GetValueOrDefault(p.CategoryId, "?"), p.Total))
.OrderByDescending(p => p.TotalMinorUnits)
.ToList(),
perCategory.Sum(p => p.Total),
code, minor);
}

public async Task<ProfitReport> GetProfitAsync(
DateOnly from, DateOnly to, CancellationToken ct = default)
{
var revenue = await db.SalesOrders
.Where(o => o.Status == SalesOrderStatus.Confirmed
&& o.OrderDate >= from && o.OrderDate <= to)
.SumAsync(o => o.TotalAmount.MinorUnits, ct);
var expenses = await db.Expenses
.Where(e => e.Date >= from && e.Date <= to)
.SumAsync(e => e.AmountMinorUnits, ct);

var (code, minor) = await AccountCurrencyAsync(ct);
return new ProfitReport(revenue, expenses, revenue - expenses, code, minor);
}

private async Task<(string Code, int Minor)> AccountCurrencyAsync(CancellationToken ct)
{
var account = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(ct);
return (account?.DefaultCurrencyCode ?? "", account?.DefaultCurrencyMinorUnit ?? 2);
}
}
Loading
Loading