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
7 changes: 7 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,13 @@ the sum of each **Sales line**'s `quantity_base`, where
page's Money section shows the total next to Revenue and a split by grade;
voided and draft orders, out-of-range orders, and non-egg lines never count.

**Feed cost per dozen (#1298)** — Σ `FeedUsage.EstimatedCost` with `Date` in
the report range, over the eggs the production report counts (official entries
only), × 12. Returned in minor units with the report's currency and rounded to
a whole minor unit, midpoint to even; a range with no eggs has no value (a
dash). Feed taken from a lot with no unit cost adds zero, so the figure can
read low. Shown in the Money section next to Expenses and profit.

**Worker sale allocation policy (#612)** — one farm setting deciding how a
restricted plain **Worker's** confirmation may draw stock: `AssignedFlocksOnly`
(default for every farm) tries the worker's own assigned flocks first, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ namespace Cluckwork.Application.Modules.Insights.Contracts;

// "Basic" deliberately: confirmed revenue − recorded expenses, no COGS or
// inventory valuation. Both operands shipped so the figure is auditable.
//
// `FeedCostPerDozenMinorUnits` (#1298): rounded to a whole minor unit,
// midpoint to even; null when the range collected no eggs. Every FeedUsage
// row is in the account's current currency — see ReportQueries.
public sealed record ProfitReport(
long RevenueMinorUnits, long ExpensesMinorUnits, long ProfitMinorUnits,
long FeedCostMinorUnits, long? FeedCostPerDozenMinorUnits,
string CurrencyCode, int CurrencyMinorUnit);
5 changes: 3 additions & 2 deletions src/Cluckwork.Domain/Common/Architecture/Modules/Insights.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ namespace Cluckwork.Domain.Common.Architecture;
"Cluckwork.Infrastructure.Modules.Insights.Repositories.ReportQueries")]
[ModuleEdge(
"Insights", "GeneralInventory", "R",
"ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. No inventory mutation or cross-owner SQL join is introduced.",
"Cluckwork.Infrastructure.Modules.Insights.Repositories.ExportQueries")]
"ExportQueries streams InventoryItems, InventoryLots, InventoryMovements and FeedUsages as typed CSV rows. ReportQueries aggregates FeedUsages.EstimatedCost for the feed-cost-per-dozen KPI (#1298). No inventory mutation or cross-owner SQL join is introduced.",
"Cluckwork.Infrastructure.Modules.Insights.Repositories.ExportQueries",
"Cluckwork.Infrastructure.Modules.Insights.Repositories.ReportQueries")]
internal static class InsightsModuleRules { }
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,26 @@ public async Task<ProfitReport> GetProfitAsync(
.SumAsync(e => e.AmountMinorUnits, ct);

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

// #1298 — spec §19.3. The denominator is the same official eggs the
// production report counts (OfficialStatuses above); rounded to a whole
// minor unit, midpoint to even; null, not 0, when the range collected
// no eggs. No per-row currency filter: every FeedUsage row is in the
// account's current currency, because FeedUsageRepository implements
// Farm's ICurrencyBoundRowSource, so Account.CurrencyLocked forbids a
// currency change while feed rows exist (#1097).
var feedCost = await db.FeedUsages
.Where(u => u.Date >= from && u.Date <= to)
.SumAsync(u => u.EstimatedCost.MinorUnits, ct);
var totalEggs = await db.DailyEntries
.Where(e => e.Date >= from && e.Date <= to && OfficialStatuses.Contains(e.Status))
.SumAsync(e => e.TotalEggs, ct);

return new ProfitReport(
revenue, expenses, revenue - expenses,
feedCost,
totalEggs > 0 ? (long)Math.Round(feedCost * 12m / totalEggs) : null,
code, minor);
}

private async Task<(string Code, int Minor)> AccountCurrencyAsync(CancellationToken ct)
Expand Down
152 changes: 151 additions & 1 deletion tests/Cluckwork.Api.IntegrationTests/ReportsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ private sealed record SalesDto(
int EggsSold, List<GradeRow> EggsSoldByGrade, string CurrencyCode, int CurrencyMinorUnit);
private sealed record ProfitDto(
long RevenueMinorUnits, long ExpensesMinorUnits, long ProfitMinorUnits,
long FeedCostMinorUnits, long? FeedCostPerDozenMinorUnits,
string CurrencyCode, int CurrencyMinorUnit);

private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.UtcNow.Date);
Expand Down Expand Up @@ -1088,6 +1089,140 @@ await client.PostWithKeyAsync($"/api/v1/sales/{orderId}/payments", Guid.NewGuid(
Assert.Equal(3100, profit.ProfitMinorUnits);
}

// #1298 — feed cost per dozen (spec §19.3): Σ FeedUsage.EstimatedCost with
// Date in the range, over the eggs the production report counts. The range
// filter and the official-only denominator are asserted together; the
// midpoint rule (to even) has its own test below.
[Fact]
public async Task Profit_FeedCostPerDozen_SumsInRangeFeed_OverOfficialEggs()
{
var email = $"u-{Guid.NewGuid():N}@test.local";
var accountId = await factory.SeedAccountWithUserAsync(email);
var farmId = Guid.NewGuid();
var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large");
var flockId = await factory.SeedFlockAsync(accountId, farmId);
var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email));

var itemId = await SeedFeedItemAsync(client);
// Received before the earliest usage: a backdated feeding cannot
// consume a lot that had not arrived yet.
await PurchaseAsync(client, itemId, 100m, 2_500, Today.AddDays(-15));
// In range: 10 kg + 4 kg @ 2500 = 35_000. Out of range: excluded.
await UsageAsync(client, itemId, flockId, 10m, Today);
await UsageAsync(client, itemId, flockId, 4m, Today);
await UsageAsync(client, itemId, flockId, 3m, Today.AddDays(-10));

// 240 official eggs, 24 of them discarded: the denominator is the
// production report's TotalEggs, not its sellable eggs (216) — that
// wrong denominator would answer 1_944. 240 = 20 dozen →
// 35_000 × 12 / 240 = 1_750.
var entryId = (await (await client.PostWithKeyAsync(
"/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = Today,
totalEggs = 240, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 24, mortalityCount = 0,
grades = new[] { new { eggGradeId = grades["Large"], quantity = 216 } }
})).Content.ReadFromJsonAsync<Created>())!.Id;
await client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString());

var profit = await client.GetFromJsonAsync<ProfitDto>(
$"/api/v1/reports/profit?from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}");
Assert.Equal(35_000, profit!.FeedCostMinorUnits);
var production = await client.GetFromJsonAsync<ProductionDto>(
$"/api/v1/reports/production?from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}");
Assert.Equal(240, production!.TotalEggs);
Assert.Equal(1_750, profit.FeedCostPerDozenMinorUnits);
Assert.Equal(35_000 * 12 / production.TotalEggs, profit.FeedCostPerDozenMinorUnits);
}

// #1298 — the per-dozen minor units round with the server's default
// midpoint rule, to even: 2_050 × 12 / 240 = 102.5 exactly → 102, not 103.
[Fact]
public async Task Profit_FeedCostPerDozen_RoundsMidpointToEven()
{
var email = $"u-{Guid.NewGuid():N}@test.local";
var accountId = await factory.SeedAccountWithUserAsync(email);
var farmId = Guid.NewGuid();
var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large");
var flockId = await factory.SeedFlockAsync(accountId, farmId);
var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email));

var itemId = await SeedFeedItemAsync(client);
await PurchaseAsync(client, itemId, 100m, 2_050, Today.AddDays(-5));
await UsageAsync(client, itemId, flockId, 1m, Today);

var entryId = (await (await client.PostWithKeyAsync(
"/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = Today,
totalEggs = 240, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 24, mortalityCount = 0,
grades = new[] { new { eggGradeId = grades["Large"], quantity = 216 } }
})).Content.ReadFromJsonAsync<Created>())!.Id;
await client.PostWithKeyAsync($"/api/v1/daily-entries/{entryId}/submit", Guid.NewGuid().ToString());

var profit = await client.GetFromJsonAsync<ProfitDto>(
$"/api/v1/reports/profit?from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}");
Assert.Equal(2_050, profit!.FeedCostMinorUnits);
Assert.Equal(102, profit.FeedCostPerDozenMinorUnits);
}

// #1298 — a range with no eggs has no per-dozen value: null, not 0 or
// infinity. The feed total is still reported, and a Draft entry's eggs do
// not count — only official entries feed the denominator.
[Fact]
public async Task Profit_ZeroEggRange_NullPerDozen()
{
var email = $"u-{Guid.NewGuid():N}@test.local";
var accountId = await factory.SeedAccountWithUserAsync(email);
var farmId = Guid.NewGuid();
var grades = await factory.SeedEggGradesAsync(accountId, farmId, "Large");
var flockId = await factory.SeedFlockAsync(accountId, farmId);
var client = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(email));

var itemId = await SeedFeedItemAsync(client);
await PurchaseAsync(client, itemId, 100m, 2_500, Today.AddDays(-5));
await UsageAsync(client, itemId, flockId, 1m, Today);

// A Draft in range: its 50 eggs are not official, so still no denominator.
await client.PostWithKeyAsync("/api/v1/daily-entries", Guid.NewGuid().ToString(), new
{
farmId, houseId = Guid.NewGuid(), flockId, date = Today,
totalEggs = 50, crackedEggs = 0, dirtyEggs = 0, discardedEggs = 0, mortalityCount = 0,
grades = new[] { new { eggGradeId = grades["Large"], quantity = 50 } }
});

var profit = await client.GetFromJsonAsync<ProfitDto>(
$"/api/v1/reports/profit?from={Today:yyyy-MM-dd}&to={Today:yyyy-MM-dd}");
Assert.Equal(2_500, profit!.FeedCostMinorUnits);
Assert.Null(profit.FeedCostPerDozenMinorUnits);
}

private static async Task<Guid> SeedFeedItemAsync(HttpClient client)
{
var item = await client.PostWithKeyAsync("/api/v1/inventory/items", Guid.NewGuid().ToString(),
new { name = "Layer feed", category = "Feed", unit = "kg", defaultUnitCostMinorUnits = 2500 });
item.EnsureSuccessStatusCode();
return (await item.Content.ReadFromJsonAsync<Created>())!.Id;
}

private static async Task PurchaseAsync(
HttpClient client, Guid itemId, decimal quantity, long cost, DateOnly date)
{
var response = await client.PostWithKeyAsync(
$"/api/v1/inventory/items/{itemId}/purchases", Guid.NewGuid().ToString(),
new { receivedDate = date, quantity, unitCostMinorUnits = cost });
response.EnsureSuccessStatusCode();
}

private static async Task UsageAsync(
HttpClient client, Guid itemId, Guid flockId, decimal quantity, DateOnly date)
{
var response = await client.PostWithKeyAsync(
$"/api/v1/inventory/items/{itemId}/usage", Guid.NewGuid().ToString(),
new { flockId, date, quantity });
response.EnsureSuccessStatusCode();
}

// #1296 — eggs sold count QuantityBase (selling units × eggs per unit) on
// CONFIRMED in-range EGG lines of the order set revenue sums: dozens and
// trays count as eggs; drafts, voided orders, orders outside EITHER date
Expand Down Expand Up @@ -1227,20 +1362,35 @@ public async Task RangeGuards_And_RoleSplit()
var accountId = await factory.SeedAccountWithUserAsync(adminEmail);
var workerEmail = $"w-{Guid.NewGuid():N}@test.local";
await factory.SeedUserAsync(accountId, workerEmail, asAdmin: false);
// #1298 — a denied non-admin role beside the Worker: money summaries
// are AdminOnly, reads included.
var salesEmail = $"s-{Guid.NewGuid():N}@test.local";
await factory.SeedUserAsync(accountId, salesEmail, Cluckwork.Domain.Modules.Farm.Contracts.Roles.Sales);
var managerEmail = $"m-{Guid.NewGuid():N}@test.local";
await factory.SeedUserAsync(accountId, managerEmail, Cluckwork.Domain.Modules.Farm.Contracts.Roles.Manager);
var admin = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(adminEmail));
var worker = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(workerEmail));
var sales = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(salesEmail));
var manager = factory.CreateAuthedClient(await factory.LoginForAccessTokenAsync(managerEmail));

// from > to and >366 days → 400.
Assert.Equal(HttpStatusCode.BadRequest, (await admin.GetAsync(
$"/api/v1/reports/production?from={Today:yyyy-MM-dd}&to={Today.AddDays(-1):yyyy-MM-dd}")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await admin.GetAsync(
$"/api/v1/reports/production?from={Today.AddDays(-400):yyyy-MM-dd}&to={Today:yyyy-MM-dd}")).StatusCode);

// Workers read production, not money.
// Workers and Sales read production, not money. A Manager can:
// AdminOnly means Owner + Manager since #103, and the SPA's isAdmin
// matches, so the issue's "a Manager cannot read it" does not hold on
// this endpoint.
Assert.Equal(HttpStatusCode.OK, (await worker.GetAsync("/api/v1/reports/production")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await worker.GetAsync("/api/v1/reports/sales")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await worker.GetAsync("/api/v1/reports/expenses")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await worker.GetAsync("/api/v1/reports/profit")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await sales.GetAsync("/api/v1/reports/production")).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await sales.GetAsync("/api/v1/reports/profit")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await manager.GetAsync("/api/v1/reports/production")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await manager.GetAsync("/api/v1/reports/profit")).StatusCode);
}

// #311 — the exact boundary of the 366-day cap: the widest allowed span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
| Commerce | R (1) | R (7) | — | W (8) fk:3 | — | — | — | — | P |
| GeneralInventory | — | R (6) | R (6) fk:3 | R (4) fk:2 | — | — | — | — | P |
| Finance | — | R (4) | R (3) fk:1 | — | — | — | — | — | P |
| Insights | — | R (1) | R (2) | R (2) | R (2) | R (1) | R (2) | — | P |
| Insights | — | R (1) | R (2) | R (2) | R (2) | R (2) | R (2) | — | P |
| Platform | A (35) | A (28) | A (26) | A (29) | A (33) | A (18) | A (9) | A (25) | — |

## Cross-owner foreign keys
Expand Down Expand Up @@ -46,7 +46,7 @@
| Insights | FlockManagement | — | R (2) |
| Insights | EggOperations | — | R (2) |
| Insights | Commerce | — | R (2) |
| Insights | GeneralInventory | — | R (1) |
| Insights | GeneralInventory | — | R (2) |
| Insights | Finance | — | R (2) |

## Cells the generator cannot observe
Expand Down
6 changes: 5 additions & 1 deletion web/scripts/verify-sw.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,11 @@ check(missingJs.length === 0, `emitted JavaScript missing from precache: ${missi
// approved by the maintainer: the base ceiling was 1,663 KiB and the branch
// measures 1,664.40 KiB. The delta is the English help prose and two glossary
// entries; es and tl are not precached since #1149.
const PRECACHE_CEILING_KIB = 1665;
//
// 2026-10-15, raised to 1,675 KiB, approved by the maintainer, to cover the
// report work in #1298, #1299 and #1300 in one decision instead of three. The
// base ceiling was 1,665 KiB; this branch measures 1,665.81 KiB.
const PRECACHE_CEILING_KIB = 1675;
let precacheBytes = 0;
const missingOnDisk = [];
for (const url of precached) {
Expand Down
7 changes: 7 additions & 0 deletions web/src/api/cluckwork/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export interface ProfitReport {
revenueMinorUnits: number;
expensesMinorUnits: number;
profitMinorUnits: number;
/**
* #1298 — Σ FeedUsage.EstimatedCost over the range, and that total × 12 over
* the official eggs the production report counts, rounded to a whole minor
* unit by the server; null per dozen when the range collected no eggs.
*/
feedCostMinorUnits: number;
feedCostPerDozenMinorUnits: number | null;
currencyCode: string;
currencyMinorUnit: number;
}
Expand Down
17 changes: 15 additions & 2 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2655,6 +2655,9 @@ export const en = {
expensesNone: "none recorded",
// {{total}} is expenses.grandTotalMinorUnits, pre-formatted (DATA).
expensesTotalSuffix: " — total {{total}}",
// #1298 — {{total}} is profit.feedCostMinorUnits, pre-formatted (DATA).
feedCostPerDozenRowLabel: "Feed cost per dozen",
feedCostLine: "{{total}} of feed recorded in the range",
profitRowLabel: "Profit (basic)",
// <Trans> (not t()) — the only JSX interleaved in this screen's prose is
// the <strong> around the profit figure, same treatment as
Expand Down Expand Up @@ -3655,8 +3658,10 @@ export const en = {
reportsMoney:
"<strong>Money</strong> (admin): sales summary for the range's orders (revenue / paid / outstanding), "
+ "<strong>Eggs sold</strong> — individual eggs on confirmed egg lines, with the by-grade split — "
+ "expenses by category, and <strong>basic profit</strong> — confirmed revenue minus recorded expenses, "
+ "no cost-of-goods.",
+ "expenses by category, <strong>Feed cost per dozen</strong> — feed cost in the range divided by "
+ "dozens collected (official eggs ÷ 12; a range with no eggs shows a dash). Feed taken from a lot "
+ "with no unit cost adds zero, so the figure can read low — and <strong>basic profit</strong> — "
+ "confirmed revenue minus recorded expenses, no cost-of-goods.",
reportsThrottle:
"<strong>If a report is refused</strong>: the farm runs only a few reports at a time, so one "
+ "busy screen cannot slow the app for everyone else. Asking for several at once — a few people "
Expand Down Expand Up @@ -4240,6 +4245,14 @@ export const en = {
+ "eggs-per-unit factor, egg lines only. The Reports page's Money section shows the total next to "
+ "Revenue, with the split by grade.",

// #1298 — the §19.3 feed-cost KPI. Zero-cost lots are said plainly: the
// estimate rests on lot unit costs, and a lot without one contributes 0.
glossaryFeedCostPerDozenTerm: "Feed cost per dozen",
glossaryFeedCostPerDozenDef:
"Feed recorded in the period over the eggs collected, × 12. Official entries only — the same eggs "
+ "the production report counts — and a period with no eggs shows a dash. Feed taken from a lot "
+ "with no unit cost adds zero, so the figure can read low. Shown in the Reports page's Money section.",

// #1297 — the two period rates. Both follow the Cracked and Dirty grades'
// saleable setting via FromCounts, captured at submit time.
glossarySellablePctTerm: "Sellable %",
Expand Down
Loading