diff --git a/AGENTS.md b/AGENTS.md index 07b414b6c..d14953a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati ### Data and correctness +- **Business dates, row timestamps and list sequences are separate facts (#819).** Keep farm operational dates as `DateOnly`. Every mapped business record except the explicitly excluded `AuditEvent` carries a PostgreSQL-stamped `DateTimeOffset CreatedAtUtc`; a record that supports an in-place update also carries `DateTimeOffset UpdatedAtUtc`, set equal to creation time on insert and replaced on each update. Factories and handlers never stamp these generic fields. A chronological list orders by business date, `CreatedAtUtc`, then a shadow per-table `bigint GENERATED ALWAYS AS IDENTITY` `Sequence`, all in the list's direction; a list with no business date omits that key. `Sequence` resolves equal timestamps and completes the order for a fixed dataset; it does not repair clock rollback or make offset pagination snapshot-stable across concurrent inserts. Give `Sequence` a database uniqueness constraint and never expose it through domain objects, responses, exports or screens. Name-ordered lookup lists and canonical FIFO or `FOR UPDATE` ordering do not gain a sequence. Classify every new mapped table through `ICreatedRecord`, `IMutableRecord` or an explicit framework or infrastructure exclusion, and add it to the chronological census only when users page or read it by time. Preserve action-specific timestamps and `Version`. Legacy audit backfills use only exact whitelisted row events and fall back to the documented unknown sentinel. → [`819-business-record-chronology.md`](docs/decisions/819-business-record-chronology.md) + - **Every aggregate mutation must bump `Version`.** `Version` is an EF concurrency token (`IsConcurrencyToken`): EF puts the *original* value in the UPDATE's `WHERE` but never auto-increments it, so a mutation without `Version++` silently loses concurrent races — both writers match `WHERE Version = N` — instead of 409ing. This shipped three times; each fix carries a parallel-race integration test, and so must any new mutation. - **Multi-tenancy:** every tenant-owned entity has `AccountId`, enforced by an EF **global query filter** plus a **`TenantStampInterceptor`** (stamps on insert, and rejects a mismatched write on update/delete). `TenantContext` resolves per-request from the JWT `account_id` claim and is **single-assignment** — a differing re-resolve throws; at startup it is unresolved, so seeders use `IgnoreQueryFilters()`. Several farms now coexist on one deployment: sign-in takes a farm code, and one email address can belong to a user in more than one farm. **`AccountId` is also an EF concurrency token on every entity that carries one (#562):** the `UPDATE`/`DELETE` the database runs carries `AccountId = `, so a detached stub aimed at another farm's row matches nothing — the interceptor alone cannot see a detached write, and an owned-only edit on an attached stub was writing through until the token landed. The token comes from a model walk in `AppDbContext.OnModelCreating`, so a new entity is covered automatically; never remove it, and a database refusal under a resolved tenant is logged as `Tenant.WriteRefusedByDatabase`. **`AccountId` must be a non-nullable `Guid`, and both layers now fail closed on anything else (#673):** the walk throws `TenantAccountIdShapeException` at model build for any other CLR type and the interceptor throws it at `SaveChanges` for a mapped type that is not `Guid` or a value that does not box to one — before, a `Guid?` or a strongly-typed id got no token and no check, which is #562's detached-write hole reopened by one mapping with every test green. Map a new tenant-owned entity's `AccountId` as a plain `Guid`; a strongly-typed id there needs this decision reopened, not a cast. **Identity's `AspNetUserRoles` carries a shadow `AccountId` plus a composite foreign key to `AspNetUsers(Id, AccountId)` (#670):** both layers select by property NAME, so the shadow column is stamped, verified and tokened like any other, and the FK makes a role grant to another farm's user — or under no resolved tenant — a database refusal; the other four user-keyed Identity tables have no writer in `src/` and are recorded as accepted risk. → [`530-multi-farm-tenancy.md`](docs/decisions/530-multi-farm-tenancy.md) - **A farm code changes only through `Account.Rename`, reached by the `rename-account` verb (#732).** No endpoint, no Settings field, and never a raw `UPDATE` — the hand-guarded `UPDATE "Accounts"` that preceded it bumped `Version` by hand, wrote no audit row and checked neither the slug pattern nor the reserved set. `Rename` validates through the same `TryValidateSlug` provisioning uses, bumps `Version` on a real change and deliberately does **not** bump it on a no-op (a rename to the code the farm already has mutates nothing, so "every mutation bumps `Version`" still holds and a Farm Settings form held open does not break). The service resolves the code to an id, resolves the tenant, takes the tenant-keyed `FOR UPDATE`, and then **re-compares the locked row against the snapshot the lookup took — both its slug and its `Version`** — lookup and lock are two statements, so a write that committed in between would otherwise be silently overwritten. Neither half of that comparison is redundant: `Version` catches the ABA schedule (renamed away and back, so the code the lookup read is the code the lock finds), the slug comparison catches a code change that never advanced `Version` (a raw `UPDATE`), and the price is that a rename racing a Farm Settings save or a suspend also returns `Account.SlugStale`. `IX_Accounts_Slug` and the `DbUpdateException` catch are the authority on the destination code; the pre-read in front of them is convenience. There is **no retired-code list**: a code a farm has moved off is immediately reusable, so `--slug ` targets whoever holds it now — an accepted cost, which is why the verb and the runbook both say to run `list-accounts` immediately first. → [`732-farm-code-rename.md`](docs/decisions/732-farm-code-rename.md) diff --git a/docs/decisions/819-business-record-chronology.md b/docs/decisions/819-business-record-chronology.md new file mode 100644 index 000000000..23ee7c802 --- /dev/null +++ b/docs/decisions/819-business-record-chronology.md @@ -0,0 +1,108 @@ +# Business dates, row timestamps, and list sequences are separate facts (#819) + +## What happened + +Several lists ordered rows by a farm business date and then by a random UUID. +Rows from the same day therefore had a stable but arbitrary order. A recent row +could appear below older rows or beyond the first page. The first change added a +database identity sequence to five lists. A full model walk found six more +chronological lists and showed that most business tables also lacked consistent +creation and update metadata. + +## The rule + +Farm business dates remain `DateOnly`. They describe the day on which farm work +belongs. `CreatedAtUtc` and `UpdatedAtUtc` describe database writes. Every mapped +business record except `AuditEvent` implements `ICreatedRecord`. A record that +supports an in-place update implements `IMutableRecord`. PostgreSQL stamps both +fields with `timestamp with time zone` values. On insert, mutable rows receive +the same value for both fields. On update, PostgreSQL preserves `CreatedAtUtc` +and replaces `UpdatedAtUtc`. + +The business-record census includes the 25 domain record types other than +`AuditEvent`, plus `ApplicationUser`. `AuditEvent` keeps its established +`OccurredAtUtc` meaning. ASP.NET Identity support types, including passkey data, +and the operational refresh-token, idempotency, durable-job, and simulation-seed +tables retain their own lifecycle fields and do not join this convention. + +Eleven chronological tables also carry a shadow `bigint GENERATED ALWAYS AS +IDENTITY` property named `Sequence`: `SalesOrders`, `Expenses`, `DailyEntries`, +`EggLots`, `BirdMovements`, `Payments`, `InventoryLots`, `FeedUsages`, +`WaterUsages`, `InventoryMovements`, and `EggInventoryMovements`. Each table has +a unique constraint on `Sequence`. Interactive lists with a business date order +by business date, creation time, and sequence, all descending. +`EggInventoryMovements` has no independent business date and orders by creation +time and sequence. The sequence stays outside domain objects, API responses, +exports, and screens. + +The generic timestamps are persistence metadata in this change. Existing API +and CSV fields stay compatible; a later contract change can expose the new +timestamps where a user needs them. Datasets that already exported a creation +timestamp continue to do so. + +Exports keep their established direction but use the same complete tuple in +that direction. FIFO selection and `FOR UPDATE` lock acquisition keep their +existing business-date and UUID order because those queries define allocation +behavior and canonical lock order rather than presentation chronology. +The flock export also keeps its established `PlacementDate, Id` order: flock +screens are name-ordered, so flocks are not a chronological paged list and do +not gain a persistence sequence solely for export formatting. + +## Existing rows + +The migration preserves every stored `CreatedAtUtc`. For a table that did not +store it, the migration uses the earliest exact creation audit whose +`EntityType`, `EntityId`, and action match a frozen table-specific whitelist. +If no such event exists, it uses `1970-01-01T00:00:00Z` as an explicit unknown +sentinel. The sentinel is not a recovered historical time. +Fresh databases replay the same migration history, so base reference rows that +predate this migration also receive the sentinel; it means unknown there too, +not that those records were created in 1970. + +For mutable rows, the migration uses the latest exact mutating audit from a +frozen table-specific whitelist. It ignores read-only actions and events aimed +at a parent record. If no qualifying update exists, `UpdatedAtUtc` equals +`CreatedAtUtc`. The migration never infers a write time from a business date or +a UUID. + +Legacy sequence values provide deterministic order only. They cannot recover +the original insertion order. For new rows, PostgreSQL allocates `Sequence` at +insert time, not commit time. A transaction that commits later may have a lower +sequence than a transaction that commits earlier. Neither a timestamp nor an +identity sequence claims exact commit chronology. + +`CreatedAtUtc` deliberately precedes `Sequence` in the agreed display tuple. +The sequence therefore resolves equal timestamps; it does not repair a system +clock that moves backwards. Offset pagination has a complete order for a fixed +dataset, but concurrent inserts can still move rows between pages. Cursor or +snapshot pagination would be a separate contract. + +## Why PostgreSQL owns the timestamps + +EF interceptors do not see `ExecuteUpdateAsync` or raw SQL. Cluckwork uses both, +and an owned-value update can also change a database row without presenting the +same tracked shape as an ordinary aggregate edit. PostgreSQL triggers cover all +of these write paths. Read-only interfaces expose the metadata without giving +handlers or factories another stamping path. + +Action-specific fields remain separate. `OccurredAtUtc`, `LockedAtUtc`, +`ReleasedOnUtc`, `DisabledAt`, `FarmLogo.UpdatedAt`, and +`FarmLogo.BannerUpdatedAt` keep their existing meanings. Generic timestamps do +not replace audit history, domain event times, or optimistic `Version` tokens. + +## Migration and enforcement + +The unmerged sequence migration is replaced by one chronology migration. The +migration adds and backfills columns before it installs triggers. It takes table +locks and runs through the pre-deploy migration process. Schema documentation +is regenerated from the final model. + +The migration begins with a five-second `lock_timeout`. That limits how long it +waits to acquire a lock, not how long acquired locks remain held; the migration +transaction retains them until commit. CI proves the upgrade on its fixture, +not its duration at production volume, so deployment must time it on a +production-sized copy before running the pre-deploy job. + +An executable model census classifies every mapped type as mutable, +create-only, or excluded. It also fixes the set of chronological tables. A new +mapped type cannot rely on a developer remembering this document alone. diff --git a/docs/schema/README.md b/docs/schema/README.md index 11bab78a2..3c26313a0 100644 --- a/docs/schema/README.md +++ b/docs/schema/README.md @@ -31,43 +31,50 @@ egg grades, and six packed-unit conversions — as guarded raw SQL | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | | [public.__EFMigrationsHistory](public.__EFMigrationsHistory.md) | 2 | | BASE TABLE | -| [public.Accounts](public.Accounts.md) | 19 | | BASE TABLE | +| [public.Accounts](public.Accounts.md) | 21 | | BASE TABLE | | [public.AspNetRoles](public.AspNetRoles.md) | 4 | | BASE TABLE | -| [public.AspNetUsers](public.AspNetUsers.md) | 24 | | BASE TABLE | +| [public.AspNetUsers](public.AspNetUsers.md) | 26 | | BASE TABLE | | [public.AuditEvents](public.AuditEvents.md) | 11 | | BASE TABLE | -| [public.Customers](public.Customers.md) | 8 | | BASE TABLE | -| [public.DailyEntries](public.DailyEntries.md) | 19 | | BASE TABLE | +| [public.Customers](public.Customers.md) | 10 | | BASE TABLE | +| [public.DailyEntries](public.DailyEntries.md) | 22 | | BASE TABLE | | [public.durable_jobs](public.durable_jobs.md) | 9 | | BASE TABLE | -| [public.EggGrades](public.EggGrades.md) | 10 | | BASE TABLE | -| [public.EggUnitConversions](public.EggUnitConversions.md) | 6 | | BASE TABLE | -| [public.ExpenseCategories](public.ExpenseCategories.md) | 6 | | BASE TABLE | -| [public.FarmLogos](public.FarmLogos.md) | 18 | | BASE TABLE | -| [public.Flocks](public.Flocks.md) | 12 | | BASE TABLE | +| [public.EggGrades](public.EggGrades.md) | 12 | | BASE TABLE | +| [public.EggUnitConversions](public.EggUnitConversions.md) | 8 | | BASE TABLE | +| [public.ExpenseCategories](public.ExpenseCategories.md) | 8 | | BASE TABLE | +| [public.FarmLogos](public.FarmLogos.md) | 20 | | BASE TABLE | +| [public.Flocks](public.Flocks.md) | 14 | | BASE TABLE | | [public.idempotency_records](public.idempotency_records.md) | 13 | | BASE TABLE | -| [public.InventoryItems](public.InventoryItems.md) | 11 | | BASE TABLE | -| [public.Products](public.Products.md) | 12 | | BASE TABLE | +| [public.InventoryItems](public.InventoryItems.md) | 13 | | BASE TABLE | +| [public.Products](public.Products.md) | 14 | | BASE TABLE | | [public.refresh_tokens](public.refresh_tokens.md) | 11 | | BASE TABLE | | [public.simulation_seed_state](public.simulation_seed_state.md) | 3 | | BASE TABLE | -| [public.UserRoleAssignments](public.UserRoleAssignments.md) | 6 | | BASE TABLE | +| [public.UserRoleAssignments](public.UserRoleAssignments.md) | 7 | | BASE TABLE | | [public.AspNetRoleClaims](public.AspNetRoleClaims.md) | 4 | | BASE TABLE | | [public.AspNetUserClaims](public.AspNetUserClaims.md) | 4 | | BASE TABLE | | [public.AspNetUserLogins](public.AspNetUserLogins.md) | 4 | | BASE TABLE | | [public.AspNetUserRoles](public.AspNetUserRoles.md) | 3 | | BASE TABLE | | [public.AspNetUserTokens](public.AspNetUserTokens.md) | 4 | | BASE TABLE | -| [public.SalesOrders](public.SalesOrders.md) | 13 | | BASE TABLE | -| [public.DailyEntryGrades](public.DailyEntryGrades.md) | 5 | | BASE TABLE | -| [public.EggLots](public.EggLots.md) | 10 | | BASE TABLE | -| [public.BirdMovements](public.BirdMovements.md) | 8 | | BASE TABLE | -| [public.Expenses](public.Expenses.md) | 12 | | BASE TABLE | -| [public.WaterUsages](public.WaterUsages.md) | 13 | | BASE TABLE | -| [public.FeedUsages](public.FeedUsages.md) | 14 | | BASE TABLE | -| [public.InventoryLots](public.InventoryLots.md) | 12 | | BASE TABLE | -| [public.ProductEggGradeMappings](public.ProductEggGradeMappings.md) | 4 | | BASE TABLE | -| [public.Payments](public.Payments.md) | 14 | | BASE TABLE | -| [public.SalesOrderItems](public.SalesOrderItems.md) | 15 | | BASE TABLE | -| [public.EggInventoryMovements](public.EggInventoryMovements.md) | 9 | | BASE TABLE | -| [public.InventoryMovements](public.InventoryMovements.md) | 13 | | BASE TABLE | -| [public.SalesOrderAllocations](public.SalesOrderAllocations.md) | 7 | | BASE TABLE | +| [public.SalesOrders](public.SalesOrders.md) | 16 | | BASE TABLE | +| [public.DailyEntryGrades](public.DailyEntryGrades.md) | 7 | | BASE TABLE | +| [public.EggLots](public.EggLots.md) | 13 | | BASE TABLE | +| [public.BirdMovements](public.BirdMovements.md) | 10 | | BASE TABLE | +| [public.Expenses](public.Expenses.md) | 15 | | BASE TABLE | +| [public.WaterUsages](public.WaterUsages.md) | 15 | | BASE TABLE | +| [public.FeedUsages](public.FeedUsages.md) | 15 | | BASE TABLE | +| [public.InventoryLots](public.InventoryLots.md) | 15 | | BASE TABLE | +| [public.ProductEggGradeMappings](public.ProductEggGradeMappings.md) | 6 | | BASE TABLE | +| [public.Payments](public.Payments.md) | 17 | | BASE TABLE | +| [public.SalesOrderItems](public.SalesOrderItems.md) | 17 | | BASE TABLE | +| [public.EggInventoryMovements](public.EggInventoryMovements.md) | 10 | | BASE TABLE | +| [public.InventoryMovements](public.InventoryMovements.md) | 14 | | BASE TABLE | +| [public.SalesOrderAllocations](public.SalesOrderAllocations.md) | 9 | | BASE TABLE | + +## Stored procedures and functions + +| Name | ReturnType | Arguments | Type | +| ---- | ------- | ------- | ---- | +| public.StampMutableBusinessRecord | trigger | | FUNCTION | +| public.StampCreatedBusinessRecord | trigger | | FUNCTION | ## Relations @@ -135,6 +142,8 @@ erDiagram varchar_32_ Slug varchar_24_ WorkerSaleAllocationPolicy integer MaxDiscountBasisPoints + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AspNetRoles" { uuid Id @@ -167,6 +176,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AuditEvents" { uuid Id @@ -190,6 +201,8 @@ erDiagram varchar_1000_ Note uuid AccountId integer Version + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntries" { uuid Id @@ -211,6 +224,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.durable_jobs" { uuid Id @@ -234,6 +250,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggUnitConversions" { uuid Id @@ -242,6 +260,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.ExpenseCategories" { uuid Id @@ -250,6 +270,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.FarmLogos" { uuid Id @@ -270,6 +292,8 @@ erDiagram integer BannerHeight timestamp_with_time_zone BannerUpdatedAt integer BannerWidth + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Flocks" { uuid Id @@ -284,6 +308,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.idempotency_records" { uuid Id @@ -312,6 +338,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Products" { uuid Id @@ -326,6 +354,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.refresh_tokens" { uuid Id @@ -352,6 +382,7 @@ erDiagram uuid HouseId uuid FlockId uuid AccountId + timestamp_with_time_zone CreatedAtUtc } "public.AspNetRoleClaims" { integer Id @@ -396,6 +427,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.DailyEntryGrades" { uuid Id @@ -403,6 +437,8 @@ erDiagram uuid EggGradeId FK integer Quantity uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggLots" { uuid Id @@ -415,6 +451,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.BirdMovements" { uuid Id @@ -425,6 +464,8 @@ erDiagram varchar_500_ Note uuid DailyEntryId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + bigint Sequence } "public.Expenses" { uuid Id @@ -439,6 +480,9 @@ erDiagram varchar_500_ Note integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.WaterUsages" { uuid Id @@ -454,6 +498,8 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.FeedUsages" { uuid Id @@ -470,6 +516,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.InventoryLots" { uuid Id @@ -484,12 +531,17 @@ erDiagram integer UnitCostCurrencyMinorUnit integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.ProductEggGradeMappings" { uuid Id uuid ProductId FK uuid EggGradeId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Payments" { uuid Id @@ -506,6 +558,9 @@ erDiagram varchar_500_ VoidReason integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.SalesOrderItems" { uuid Id @@ -523,6 +578,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggInventoryMovements" { uuid Id @@ -534,6 +591,7 @@ erDiagram varchar_500_ Reason timestamp_with_time_zone CreatedAtUtc uuid AccountId + bigint Sequence } "public.InventoryMovements" { uuid Id @@ -549,6 +607,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } "public.SalesOrderAllocations" { uuid Id @@ -558,6 +617,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.Accounts.md b/docs/schema/public.Accounts.md index 8febc9793..a3107d2b4 100644 --- a/docs/schema/public.Accounts.md +++ b/docs/schema/public.Accounts.md @@ -23,6 +23,8 @@ | Slug | varchar(32) | | false | | | | | WorkerSaleAllocationPolicy | varchar(24) | 'AssignedFlocksOnly'::character varying | false | | | | | MaxDiscountBasisPoints | integer | | true | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -36,6 +38,7 @@ | ---- | ---- | ---------- | | Accounts_AccountId_not_null | n | NOT NULL "AccountId" | | Accounts_Brand_not_null | n | NOT NULL "Brand" | +| Accounts_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Accounts_DefaultCurrencyCode_not_null | n | NOT NULL "DefaultCurrencyCode" | | Accounts_DefaultCurrencyMinorUnit_not_null | n | NOT NULL "DefaultCurrencyMinorUnit" | | Accounts_DefaultStepperUnit_not_null | n | NOT NULL "DefaultStepperUnit" | @@ -46,6 +49,7 @@ | Accounts_Slug_not_null | n | NOT NULL "Slug" | | Accounts_TimeZoneId_not_null | n | NOT NULL "TimeZoneId" | | Accounts_UnitSystem_not_null | n | NOT NULL "UnitSystem" | +| Accounts_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Accounts_Version_not_null | n | NOT NULL "Version" | | Accounts_WorkerSaleAllocationPolicy_not_null | n | NOT NULL "WorkerSaleAllocationPolicy" | | CK_Accounts_MaxDiscountBasisPoints | CHECK | CHECK ((("MaxDiscountBasisPoints" IS NULL) OR (("MaxDiscountBasisPoints" >= 0) AND ("MaxDiscountBasisPoints" <= 10000)))) | @@ -58,6 +62,12 @@ | PK_Accounts | CREATE UNIQUE INDEX "PK_Accounts" ON public."Accounts" USING btree ("Id") | | IX_Accounts_Slug | CREATE UNIQUE INDEX "IX_Accounts_Slug" ON public."Accounts" USING btree ("Slug") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Accounts_BusinessRecordTimestamps | CREATE TRIGGER "TR_Accounts_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Accounts" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -85,6 +95,8 @@ erDiagram varchar_32_ Slug varchar_24_ WorkerSaleAllocationPolicy integer MaxDiscountBasisPoints + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AspNetUsers" { uuid Id @@ -111,6 +123,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.AspNetUserClaims.md b/docs/schema/public.AspNetUserClaims.md index afebe9509..79b1f6185 100644 --- a/docs/schema/public.AspNetUserClaims.md +++ b/docs/schema/public.AspNetUserClaims.md @@ -69,6 +69,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.AspNetUserLogins.md b/docs/schema/public.AspNetUserLogins.md index 0bbb9fd3a..9ac24bc72 100644 --- a/docs/schema/public.AspNetUserLogins.md +++ b/docs/schema/public.AspNetUserLogins.md @@ -70,6 +70,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.AspNetUserRoles.md b/docs/schema/public.AspNetUserRoles.md index e8e0eaef8..507d3c793 100644 --- a/docs/schema/public.AspNetUserRoles.md +++ b/docs/schema/public.AspNetUserRoles.md @@ -73,6 +73,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AspNetRoles" { uuid Id diff --git a/docs/schema/public.AspNetUserTokens.md b/docs/schema/public.AspNetUserTokens.md index 64b92b68e..4a8eaef80 100644 --- a/docs/schema/public.AspNetUserTokens.md +++ b/docs/schema/public.AspNetUserTokens.md @@ -69,6 +69,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.AspNetUsers.md b/docs/schema/public.AspNetUsers.md index aff2b296c..e20027661 100644 --- a/docs/schema/public.AspNetUsers.md +++ b/docs/schema/public.AspNetUsers.md @@ -28,6 +28,8 @@ | DisabledBy | uuid | | true | | | | | PreferredStepperUnit | varchar(16) | | true | | | | | StepUpLogoutEpoch | integer | 0 | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -41,6 +43,7 @@ | ---- | ---- | ---------- | | AspNetUsers_AccessFailedCount_not_null | n | NOT NULL "AccessFailedCount" | | AspNetUsers_AccountId_not_null | n | NOT NULL "AccountId" | +| AspNetUsers_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | AspNetUsers_CredentialEpoch_not_null | n | NOT NULL "CredentialEpoch" | | AspNetUsers_EmailConfirmed_not_null | n | NOT NULL "EmailConfirmed" | | AspNetUsers_Email_not_null | n | NOT NULL "Email" | @@ -52,6 +55,7 @@ | AspNetUsers_PhoneNumberConfirmed_not_null | n | NOT NULL "PhoneNumberConfirmed" | | AspNetUsers_StepUpLogoutEpoch_not_null | n | NOT NULL "StepUpLogoutEpoch" | | AspNetUsers_TwoFactorEnabled_not_null | n | NOT NULL "TwoFactorEnabled" | +| AspNetUsers_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | AspNetUsers_UserName_not_null | n | NOT NULL "UserName" | | FK_AspNetUsers_Accounts_AccountId | FOREIGN KEY | FOREIGN KEY ("AccountId") REFERENCES "Accounts"("Id") ON DELETE RESTRICT | | PK_AspNetUsers | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -66,6 +70,12 @@ | UserNameIndex | CREATE UNIQUE INDEX "UserNameIndex" ON public."AspNetUsers" USING btree ("AccountId", "NormalizedUserName") | | AK_AspNetUsers_Id_AccountId | CREATE UNIQUE INDEX "AK_AspNetUsers_Id_AccountId" ON public."AspNetUsers" USING btree ("Id", "AccountId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_AspNetUsers_BusinessRecordTimestamps | CREATE TRIGGER "TR_AspNetUsers_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."AspNetUsers" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -103,6 +113,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AspNetUserClaims" { integer Id @@ -147,6 +159,8 @@ erDiagram varchar_32_ Slug varchar_24_ WorkerSaleAllocationPolicy integer MaxDiscountBasisPoints + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.BirdMovements.md b/docs/schema/public.BirdMovements.md index 5b31dca5f..38cb579f7 100644 --- a/docs/schema/public.BirdMovements.md +++ b/docs/schema/public.BirdMovements.md @@ -12,6 +12,8 @@ | Note | varchar(500) | | true | | | | | DailyEntryId | uuid | | true | | [public.DailyEntries](public.DailyEntries.md) | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -24,10 +26,12 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | BirdMovements_AccountId_not_null | n | NOT NULL "AccountId" | +| BirdMovements_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | BirdMovements_Date_not_null | n | NOT NULL "Date" | | BirdMovements_FlockId_not_null | n | NOT NULL "FlockId" | | BirdMovements_Id_not_null | n | NOT NULL "Id" | | BirdMovements_Quantity_not_null | n | NOT NULL "Quantity" | +| BirdMovements_Sequence_not_null | n | NOT NULL "Sequence" | | BirdMovements_Type_not_null | n | NOT NULL "Type" | | FK_BirdMovements_DailyEntries_DailyEntryId | FOREIGN KEY | FOREIGN KEY ("DailyEntryId") REFERENCES "DailyEntries"("Id") ON DELETE RESTRICT | | FK_BirdMovements_Flocks_FlockId | FOREIGN KEY | FOREIGN KEY ("FlockId") REFERENCES "Flocks"("Id") ON DELETE RESTRICT | @@ -41,6 +45,13 @@ | IX_BirdMovements_AccountId_FlockId_Date | CREATE INDEX "IX_BirdMovements_AccountId_FlockId_Date" ON public."BirdMovements" USING btree ("AccountId", "FlockId", "Date") | | IX_BirdMovements_DailyEntryId | CREATE INDEX "IX_BirdMovements_DailyEntryId" ON public."BirdMovements" USING btree ("DailyEntryId") | | IX_BirdMovements_FlockId | CREATE INDEX "IX_BirdMovements_FlockId" ON public."BirdMovements" USING btree ("FlockId") | +| IX_BirdMovements_Sequence | CREATE UNIQUE INDEX "IX_BirdMovements_Sequence" ON public."BirdMovements" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_BirdMovements_BusinessRecordTimestamps | CREATE TRIGGER "TR_BirdMovements_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."BirdMovements" FOR EACH ROW EXECUTE FUNCTION "StampCreatedBusinessRecord"() | ## Relations @@ -59,6 +70,8 @@ erDiagram varchar_500_ Note uuid DailyEntryId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + bigint Sequence } "public.Flocks" { uuid Id @@ -73,6 +86,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntries" { uuid Id @@ -94,6 +109,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.Customers.md b/docs/schema/public.Customers.md index 4aec3d38c..106367371 100644 --- a/docs/schema/public.Customers.md +++ b/docs/schema/public.Customers.md @@ -12,6 +12,8 @@ | Note | varchar(1000) | | true | | | | | AccountId | uuid | | false | | | | | Version | integer | 0 | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -24,9 +26,11 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | Customers_AccountId_not_null | n | NOT NULL "AccountId" | +| Customers_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Customers_Id_not_null | n | NOT NULL "Id" | | Customers_Name_not_null | n | NOT NULL "Name" | | Customers_Phone_not_null | n | NOT NULL "Phone" | +| Customers_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Customers_Version_not_null | n | NOT NULL "Version" | | PK_Customers | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -37,6 +41,12 @@ | PK_Customers | CREATE UNIQUE INDEX "PK_Customers" ON public."Customers" USING btree ("Id") | | IX_Customers_AccountId_Name | CREATE INDEX "IX_Customers_AccountId_Name" ON public."Customers" USING btree ("AccountId", "Name") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Customers_BusinessRecordTimestamps | CREATE TRIGGER "TR_Customers_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Customers" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -54,6 +64,8 @@ erDiagram varchar_1000_ Note uuid AccountId integer Version + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrders" { uuid Id @@ -69,6 +81,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Payments" { uuid Id @@ -85,6 +100,9 @@ erDiagram varchar_500_ VoidReason integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.DailyEntries.md b/docs/schema/public.DailyEntries.md index 3b324d6fd..cf6b364b5 100644 --- a/docs/schema/public.DailyEntries.md +++ b/docs/schema/public.DailyEntries.md @@ -23,6 +23,9 @@ | LockedAtUtc | timestamp with time zone | | true | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -36,6 +39,7 @@ | ---- | ---- | ---------- | | DailyEntries_AccountId_not_null | n | NOT NULL "AccountId" | | DailyEntries_CrackedEggs_not_null | n | NOT NULL "CrackedEggs" | +| DailyEntries_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | DailyEntries_Date_not_null | n | NOT NULL "Date" | | DailyEntries_DirtyEggs_not_null | n | NOT NULL "DirtyEggs" | | DailyEntries_DiscardedEggs_not_null | n | NOT NULL "DiscardedEggs" | @@ -44,8 +48,10 @@ | DailyEntries_HouseId_not_null | n | NOT NULL "HouseId" | | DailyEntries_Id_not_null | n | NOT NULL "Id" | | DailyEntries_MortalityCount_not_null | n | NOT NULL "MortalityCount" | +| DailyEntries_Sequence_not_null | n | NOT NULL "Sequence" | | DailyEntries_Status_not_null | n | NOT NULL "Status" | | DailyEntries_TotalEggs_not_null | n | NOT NULL "TotalEggs" | +| DailyEntries_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | DailyEntries_Version_not_null | n | NOT NULL "Version" | | PK_DailyEntries | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -55,6 +61,13 @@ | ---- | ---------- | | PK_DailyEntries | CREATE UNIQUE INDEX "PK_DailyEntries" ON public."DailyEntries" USING btree ("Id") | | IX_DailyEntries_NaturalKey | CREATE UNIQUE INDEX "IX_DailyEntries_NaturalKey" ON public."DailyEntries" USING btree ("AccountId", "FarmId", "HouseId", "FlockId", "Date") WHERE (("Status")::text <> 'Voided'::text) | +| IX_DailyEntries_Sequence | CREATE UNIQUE INDEX "IX_DailyEntries_Sequence" ON public."DailyEntries" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_DailyEntries_BusinessRecordTimestamps | CREATE TRIGGER "TR_DailyEntries_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."DailyEntries" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -86,6 +99,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.DailyEntryGrades" { uuid Id @@ -93,6 +109,8 @@ erDiagram uuid EggGradeId FK integer Quantity uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.BirdMovements" { uuid Id @@ -103,6 +121,8 @@ erDiagram varchar_500_ Note uuid DailyEntryId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + bigint Sequence } "public.WaterUsages" { uuid Id @@ -118,6 +138,8 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.FeedUsages" { uuid Id @@ -134,6 +156,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } ``` diff --git a/docs/schema/public.DailyEntryGrades.md b/docs/schema/public.DailyEntryGrades.md index 4cbd30479..30693b291 100644 --- a/docs/schema/public.DailyEntryGrades.md +++ b/docs/schema/public.DailyEntryGrades.md @@ -9,6 +9,8 @@ | EggGradeId | uuid | | false | | [public.EggGrades](public.EggGrades.md) | | | Quantity | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -21,10 +23,12 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | DailyEntryGrades_AccountId_not_null | n | NOT NULL "AccountId" | +| DailyEntryGrades_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | DailyEntryGrades_DailyEntryId_not_null | n | NOT NULL "DailyEntryId" | | DailyEntryGrades_EggGradeId_not_null | n | NOT NULL "EggGradeId" | | DailyEntryGrades_Id_not_null | n | NOT NULL "Id" | | DailyEntryGrades_Quantity_not_null | n | NOT NULL "Quantity" | +| DailyEntryGrades_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | FK_DailyEntryGrades_DailyEntries_DailyEntryId | FOREIGN KEY | FOREIGN KEY ("DailyEntryId") REFERENCES "DailyEntries"("Id") ON DELETE CASCADE | | FK_DailyEntryGrades_EggGrades_EggGradeId | FOREIGN KEY | FOREIGN KEY ("EggGradeId") REFERENCES "EggGrades"("Id") ON DELETE RESTRICT | | PK_DailyEntryGrades | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -38,6 +42,12 @@ | IX_DailyEntryGrades_DailyEntryId_EggGradeId | CREATE UNIQUE INDEX "IX_DailyEntryGrades_DailyEntryId_EggGradeId" ON public."DailyEntryGrades" USING btree ("DailyEntryId", "EggGradeId") | | IX_DailyEntryGrades_EggGradeId | CREATE INDEX "IX_DailyEntryGrades_EggGradeId" ON public."DailyEntryGrades" USING btree ("EggGradeId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_DailyEntryGrades_BusinessRecordTimestamps | CREATE TRIGGER "TR_DailyEntryGrades_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."DailyEntryGrades" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -52,6 +62,8 @@ erDiagram uuid EggGradeId FK integer Quantity uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntries" { uuid Id @@ -73,6 +85,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.EggGrades" { uuid Id @@ -85,6 +100,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.EggGrades.md b/docs/schema/public.EggGrades.md index 70626045e..39a07b6e6 100644 --- a/docs/schema/public.EggGrades.md +++ b/docs/schema/public.EggGrades.md @@ -14,6 +14,8 @@ | DailyEntryKind | varchar(16) | 'Manual'::character varying | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -28,6 +30,7 @@ | ---- | ---- | ---------- | | EggGrades_AccountId_not_null | n | NOT NULL "AccountId" | | EggGrades_Active_not_null | n | NOT NULL "Active" | +| EggGrades_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | EggGrades_DailyEntryKind_not_null | n | NOT NULL "DailyEntryKind" | | EggGrades_FarmId_not_null | n | NOT NULL "FarmId" | | EggGrades_GradeType_not_null | n | NOT NULL "GradeType" | @@ -35,6 +38,7 @@ | EggGrades_IsSaleable_not_null | n | NOT NULL "IsSaleable" | | EggGrades_Name_not_null | n | NOT NULL "Name" | | EggGrades_SortOrder_not_null | n | NOT NULL "SortOrder" | +| EggGrades_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | EggGrades_Version_not_null | n | NOT NULL "Version" | | PK_EggGrades | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -46,6 +50,12 @@ | IX_EggGrades_AccountId_FarmId_DailyEntryKind | CREATE UNIQUE INDEX "IX_EggGrades_AccountId_FarmId_DailyEntryKind" ON public."EggGrades" USING btree ("AccountId", "FarmId", "DailyEntryKind") WHERE (("DailyEntryKind")::text <> 'Manual'::text) | | IX_EggGrades_AccountId_FarmId_LowerName | CREATE UNIQUE INDEX "IX_EggGrades_AccountId_FarmId_LowerName" ON public."EggGrades" USING btree ("AccountId", "FarmId", lower(("Name")::text)) | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_EggGrades_BusinessRecordTimestamps | CREATE TRIGGER "TR_EggGrades_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."EggGrades" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -67,6 +77,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntryGrades" { uuid Id @@ -74,6 +86,8 @@ erDiagram uuid EggGradeId FK integer Quantity uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggLots" { uuid Id @@ -86,12 +100,17 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.ProductEggGradeMappings" { uuid Id uuid ProductId FK uuid EggGradeId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrderItems" { uuid Id @@ -109,6 +128,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.EggInventoryMovements.md b/docs/schema/public.EggInventoryMovements.md index d862ac584..903899b0e 100644 --- a/docs/schema/public.EggInventoryMovements.md +++ b/docs/schema/public.EggInventoryMovements.md @@ -13,6 +13,7 @@ | Reason | varchar(500) | | true | | | | | CreatedAtUtc | timestamp with time zone | | false | | | | | AccountId | uuid | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -32,6 +33,7 @@ | EggInventoryMovements_QuantityDelta_not_null | n | NOT NULL "QuantityDelta" | | EggInventoryMovements_ReferenceId_not_null | n | NOT NULL "ReferenceId" | | EggInventoryMovements_ReferenceType_not_null | n | NOT NULL "ReferenceType" | +| EggInventoryMovements_Sequence_not_null | n | NOT NULL "Sequence" | | FK_EggInventoryMovements_EggLots_EggLotId | FOREIGN KEY | FOREIGN KEY ("EggLotId") REFERENCES "EggLots"("Id") ON DELETE RESTRICT | | PK_EggInventoryMovements | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -42,6 +44,13 @@ | PK_EggInventoryMovements | CREATE UNIQUE INDEX "PK_EggInventoryMovements" ON public."EggInventoryMovements" USING btree ("Id") | | IX_EggInventoryMovements_AccountId_EggLotId_CreatedAtUtc | CREATE INDEX "IX_EggInventoryMovements_AccountId_EggLotId_CreatedAtUtc" ON public."EggInventoryMovements" USING btree ("AccountId", "EggLotId", "CreatedAtUtc") | | IX_EggInventoryMovements_EggLotId | CREATE INDEX "IX_EggInventoryMovements_EggLotId" ON public."EggInventoryMovements" USING btree ("EggLotId") | +| IX_EggInventoryMovements_Sequence | CREATE UNIQUE INDEX "IX_EggInventoryMovements_Sequence" ON public."EggInventoryMovements" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_EggInventoryMovements_BusinessRecordTimestamps | CREATE TRIGGER "TR_EggInventoryMovements_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."EggInventoryMovements" FOR EACH ROW EXECUTE FUNCTION "StampCreatedBusinessRecord"() | ## Relations @@ -60,6 +69,7 @@ erDiagram varchar_500_ Reason timestamp_with_time_zone CreatedAtUtc uuid AccountId + bigint Sequence } "public.EggLots" { uuid Id @@ -72,6 +82,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.EggLots.md b/docs/schema/public.EggLots.md index 0fd1da5bc..b92855dc0 100644 --- a/docs/schema/public.EggLots.md +++ b/docs/schema/public.EggLots.md @@ -14,6 +14,9 @@ | RestrictedUntil | date | | true | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -27,12 +30,15 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | EggLots_AccountId_not_null | n | NOT NULL "AccountId" | +| EggLots_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | EggLots_EggGradeId_not_null | n | NOT NULL "EggGradeId" | | EggLots_FlockId_not_null | n | NOT NULL "FlockId" | | EggLots_Id_not_null | n | NOT NULL "Id" | | EggLots_ProductionDate_not_null | n | NOT NULL "ProductionDate" | | EggLots_QuantityAvailable_not_null | n | NOT NULL "QuantityAvailable" | | EggLots_QuantityProduced_not_null | n | NOT NULL "QuantityProduced" | +| EggLots_Sequence_not_null | n | NOT NULL "Sequence" | +| EggLots_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | EggLots_Version_not_null | n | NOT NULL "Version" | | FK_EggLots_EggGrades_EggGradeId | FOREIGN KEY | FOREIGN KEY ("EggGradeId") REFERENCES "EggGrades"("Id") ON DELETE RESTRICT | | PK_EggLots | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -45,6 +51,13 @@ | IX_EggLots_Allocation | CREATE INDEX "IX_EggLots_Allocation" ON public."EggLots" USING btree ("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") | | IX_EggLots_DailyEntryId | CREATE INDEX "IX_EggLots_DailyEntryId" ON public."EggLots" USING btree ("DailyEntryId") | | IX_EggLots_EggGradeId | CREATE INDEX "IX_EggLots_EggGradeId" ON public."EggLots" USING btree ("EggGradeId") | +| IX_EggLots_Sequence | CREATE UNIQUE INDEX "IX_EggLots_Sequence" ON public."EggLots" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_EggLots_BusinessRecordTimestamps | CREATE TRIGGER "TR_EggLots_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."EggLots" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -66,6 +79,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.EggInventoryMovements" { uuid Id @@ -77,6 +93,7 @@ erDiagram varchar_500_ Reason timestamp_with_time_zone CreatedAtUtc uuid AccountId + bigint Sequence } "public.SalesOrderAllocations" { uuid Id @@ -86,6 +103,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggGrades" { uuid Id @@ -98,6 +117,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.EggUnitConversions.md b/docs/schema/public.EggUnitConversions.md index d9dca4edb..b56406275 100644 --- a/docs/schema/public.EggUnitConversions.md +++ b/docs/schema/public.EggUnitConversions.md @@ -10,6 +10,8 @@ | Active | boolean | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -23,9 +25,11 @@ | ---- | ---- | ---------- | | EggUnitConversions_AccountId_not_null | n | NOT NULL "AccountId" | | EggUnitConversions_Active_not_null | n | NOT NULL "Active" | +| EggUnitConversions_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | EggUnitConversions_EggsPerUnit_not_null | n | NOT NULL "EggsPerUnit" | | EggUnitConversions_Id_not_null | n | NOT NULL "Id" | | EggUnitConversions_UnitCode_not_null | n | NOT NULL "UnitCode" | +| EggUnitConversions_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | EggUnitConversions_Version_not_null | n | NOT NULL "Version" | | PK_EggUnitConversions | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -36,6 +40,12 @@ | PK_EggUnitConversions | CREATE UNIQUE INDEX "PK_EggUnitConversions" ON public."EggUnitConversions" USING btree ("Id") | | IX_EggUnitConversions_AccountId_UnitCode | CREATE UNIQUE INDEX "IX_EggUnitConversions_AccountId_UnitCode" ON public."EggUnitConversions" USING btree ("AccountId", "UnitCode") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_EggUnitConversions_BusinessRecordTimestamps | CREATE TRIGGER "TR_EggUnitConversions_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."EggUnitConversions" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -49,6 +59,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.ExpenseCategories.md b/docs/schema/public.ExpenseCategories.md index be0edd689..99cd76fbd 100644 --- a/docs/schema/public.ExpenseCategories.md +++ b/docs/schema/public.ExpenseCategories.md @@ -10,6 +10,8 @@ | Active | boolean | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -23,9 +25,11 @@ | ---- | ---- | ---------- | | ExpenseCategories_AccountId_not_null | n | NOT NULL "AccountId" | | ExpenseCategories_Active_not_null | n | NOT NULL "Active" | +| ExpenseCategories_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | ExpenseCategories_FarmId_not_null | n | NOT NULL "FarmId" | | ExpenseCategories_Id_not_null | n | NOT NULL "Id" | | ExpenseCategories_Name_not_null | n | NOT NULL "Name" | +| ExpenseCategories_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | ExpenseCategories_Version_not_null | n | NOT NULL "Version" | | PK_ExpenseCategories | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -36,6 +40,12 @@ | PK_ExpenseCategories | CREATE UNIQUE INDEX "PK_ExpenseCategories" ON public."ExpenseCategories" USING btree ("Id") | | IX_ExpenseCategories_NameCi | CREATE UNIQUE INDEX "IX_ExpenseCategories_NameCi" ON public."ExpenseCategories" USING btree ("AccountId", "FarmId", lower(("Name")::text)) | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_ExpenseCategories_BusinessRecordTimestamps | CREATE TRIGGER "TR_ExpenseCategories_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."ExpenseCategories" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -50,6 +60,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Expenses" { uuid Id @@ -64,6 +76,9 @@ erDiagram varchar_500_ Note integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.Expenses.md b/docs/schema/public.Expenses.md index 240135e66..159a7f1e5 100644 --- a/docs/schema/public.Expenses.md +++ b/docs/schema/public.Expenses.md @@ -16,6 +16,9 @@ | Note | varchar(500) | | true | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -29,6 +32,7 @@ | ---- | ---- | ---------- | | Expenses_AccountId_not_null | n | NOT NULL "AccountId" | | Expenses_AmountMinorUnits_not_null | n | NOT NULL "AmountMinorUnits" | +| Expenses_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Expenses_CurrencyCode_not_null | n | NOT NULL "CurrencyCode" | | Expenses_CurrencyMinorUnit_not_null | n | NOT NULL "CurrencyMinorUnit" | | Expenses_Date_not_null | n | NOT NULL "Date" | @@ -36,6 +40,8 @@ | Expenses_ExpenseCategoryId_not_null | n | NOT NULL "ExpenseCategoryId" | | Expenses_FarmId_not_null | n | NOT NULL "FarmId" | | Expenses_Id_not_null | n | NOT NULL "Id" | +| Expenses_Sequence_not_null | n | NOT NULL "Sequence" | +| Expenses_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Expenses_Version_not_null | n | NOT NULL "Version" | | FK_Expenses_ExpenseCategories_ExpenseCategoryId | FOREIGN KEY | FOREIGN KEY ("ExpenseCategoryId") REFERENCES "ExpenseCategories"("Id") ON DELETE RESTRICT | | FK_Expenses_Flocks_FlockId | FOREIGN KEY | FOREIGN KEY ("FlockId") REFERENCES "Flocks"("Id") ON DELETE RESTRICT | @@ -50,6 +56,13 @@ | IX_Expenses_AccountId_ExpenseCategoryId | CREATE INDEX "IX_Expenses_AccountId_ExpenseCategoryId" ON public."Expenses" USING btree ("AccountId", "ExpenseCategoryId") | | IX_Expenses_ExpenseCategoryId | CREATE INDEX "IX_Expenses_ExpenseCategoryId" ON public."Expenses" USING btree ("ExpenseCategoryId") | | IX_Expenses_FlockId | CREATE INDEX "IX_Expenses_FlockId" ON public."Expenses" USING btree ("FlockId") | +| IX_Expenses_Sequence | CREATE UNIQUE INDEX "IX_Expenses_Sequence" ON public."Expenses" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Expenses_BusinessRecordTimestamps | CREATE TRIGGER "TR_Expenses_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Expenses" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -72,6 +85,9 @@ erDiagram varchar_500_ Note integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.ExpenseCategories" { uuid Id @@ -80,6 +96,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Flocks" { uuid Id @@ -94,6 +112,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.FarmLogos.md b/docs/schema/public.FarmLogos.md index 17d323712..8c70f38d4 100644 --- a/docs/schema/public.FarmLogos.md +++ b/docs/schema/public.FarmLogos.md @@ -22,6 +22,8 @@ | BannerHeight | integer | | true | | | | | BannerUpdatedAt | timestamp with time zone | | true | | | | | BannerWidth | integer | | true | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -34,8 +36,10 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | FarmLogos_AccountId_not_null | n | NOT NULL "AccountId" | +| FarmLogos_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | FarmLogos_FarmId_not_null | n | NOT NULL "FarmId" | | FarmLogos_Id_not_null | n | NOT NULL "Id" | +| FarmLogos_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | FarmLogos_Version_not_null | n | NOT NULL "Version" | | ck_farm_logos_banner_content_length | CHECK | CHECK ((("BannerContent" IS NULL) OR ((octet_length("BannerContent") > 0) AND (octet_length("BannerContent") <= 15728640)))) | | ck_farm_logos_content_length | CHECK | CHECK ((("Content" IS NULL) OR ((octet_length("Content") > 0) AND (octet_length("Content") <= 5242880)))) | @@ -48,6 +52,12 @@ | PK_FarmLogos | CREATE UNIQUE INDEX "PK_FarmLogos" ON public."FarmLogos" USING btree ("Id") | | IX_FarmLogos_AccountId_FarmId | CREATE UNIQUE INDEX "IX_FarmLogos_AccountId_FarmId" ON public."FarmLogos" USING btree ("AccountId", "FarmId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_FarmLogos_BusinessRecordTimestamps | CREATE TRIGGER "TR_FarmLogos_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."FarmLogos" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -73,6 +83,8 @@ erDiagram integer BannerHeight timestamp_with_time_zone BannerUpdatedAt integer BannerWidth + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.FeedUsages.md b/docs/schema/public.FeedUsages.md index a3f2ac91e..365ca36dd 100644 --- a/docs/schema/public.FeedUsages.md +++ b/docs/schema/public.FeedUsages.md @@ -18,6 +18,7 @@ | CreatedAtUtc | timestamp with time zone | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -40,6 +41,7 @@ | FeedUsages_Id_not_null | n | NOT NULL "Id" | | FeedUsages_InventoryItemId_not_null | n | NOT NULL "InventoryItemId" | | FeedUsages_Quantity_not_null | n | NOT NULL "Quantity" | +| FeedUsages_Sequence_not_null | n | NOT NULL "Sequence" | | FeedUsages_Unit_not_null | n | NOT NULL "Unit" | | FeedUsages_Version_not_null | n | NOT NULL "Version" | | FK_FeedUsages_DailyEntries_DailyEntryId | FOREIGN KEY | FOREIGN KEY ("DailyEntryId") REFERENCES "DailyEntries"("Id") ON DELETE RESTRICT | @@ -55,6 +57,13 @@ | IX_FeedUsages_DailyEntryId | CREATE INDEX "IX_FeedUsages_DailyEntryId" ON public."FeedUsages" USING btree ("DailyEntryId") | | IX_FeedUsages_FlockId_Date | CREATE INDEX "IX_FeedUsages_FlockId_Date" ON public."FeedUsages" USING btree ("FlockId", "Date") | | IX_FeedUsages_InventoryItemId_Date | CREATE INDEX "IX_FeedUsages_InventoryItemId_Date" ON public."FeedUsages" USING btree ("InventoryItemId", "Date") | +| IX_FeedUsages_Sequence | CREATE UNIQUE INDEX "IX_FeedUsages_Sequence" ON public."FeedUsages" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_FeedUsages_BusinessRecordTimestamps | CREATE TRIGGER "TR_FeedUsages_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."FeedUsages" FOR EACH ROW EXECUTE FUNCTION "StampCreatedBusinessRecord"() | ## Relations @@ -80,6 +89,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.Flocks" { uuid Id @@ -94,6 +104,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.InventoryItems" { uuid Id @@ -107,6 +119,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntries" { uuid Id @@ -128,6 +142,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.Flocks.md b/docs/schema/public.Flocks.md index 316c7774b..feac3489d 100644 --- a/docs/schema/public.Flocks.md +++ b/docs/schema/public.Flocks.md @@ -16,6 +16,8 @@ | ArchivedOn | date | | true | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -30,6 +32,7 @@ | ---- | ---- | ---------- | | Flocks_AccountId_not_null | n | NOT NULL "AccountId" | | Flocks_Breed_not_null | n | NOT NULL "Breed" | +| Flocks_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Flocks_FarmId_not_null | n | NOT NULL "FarmId" | | Flocks_HouseId_not_null | n | NOT NULL "HouseId" | | Flocks_Id_not_null | n | NOT NULL "Id" | @@ -37,6 +40,7 @@ | Flocks_Name_not_null | n | NOT NULL "Name" | | Flocks_PlacementDate_not_null | n | NOT NULL "PlacementDate" | | Flocks_Status_not_null | n | NOT NULL "Status" | +| Flocks_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Flocks_Version_not_null | n | NOT NULL "Version" | | PK_Flocks | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -47,6 +51,12 @@ | PK_Flocks | CREATE UNIQUE INDEX "PK_Flocks" ON public."Flocks" USING btree ("Id") | | IX_Flocks_AccountId_FarmId_HouseId | CREATE INDEX "IX_Flocks_AccountId_FarmId_HouseId" ON public."Flocks" USING btree ("AccountId", "FarmId", "HouseId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Flocks_BusinessRecordTimestamps | CREATE TRIGGER "TR_Flocks_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Flocks" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -71,6 +81,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.BirdMovements" { uuid Id @@ -81,6 +93,8 @@ erDiagram varchar_500_ Note uuid DailyEntryId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + bigint Sequence } "public.Expenses" { uuid Id @@ -95,6 +109,9 @@ erDiagram varchar_500_ Note integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.WaterUsages" { uuid Id @@ -110,6 +127,8 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.FeedUsages" { uuid Id @@ -126,6 +145,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.InventoryMovements" { uuid Id @@ -141,6 +161,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } ``` diff --git a/docs/schema/public.InventoryItems.md b/docs/schema/public.InventoryItems.md index d40e86fb0..297453251 100644 --- a/docs/schema/public.InventoryItems.md +++ b/docs/schema/public.InventoryItems.md @@ -15,6 +15,8 @@ | Active | boolean | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -29,10 +31,12 @@ | InventoryItems_AccountId_not_null | n | NOT NULL "AccountId" | | InventoryItems_Active_not_null | n | NOT NULL "Active" | | InventoryItems_Category_not_null | n | NOT NULL "Category" | +| InventoryItems_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | InventoryItems_FarmId_not_null | n | NOT NULL "FarmId" | | InventoryItems_Id_not_null | n | NOT NULL "Id" | | InventoryItems_Name_not_null | n | NOT NULL "Name" | | InventoryItems_Unit_not_null | n | NOT NULL "Unit" | +| InventoryItems_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | InventoryItems_Version_not_null | n | NOT NULL "Version" | | PK_InventoryItems | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -44,6 +48,12 @@ | IX_InventoryItems_AccountId_FarmId | CREATE INDEX "IX_InventoryItems_AccountId_FarmId" ON public."InventoryItems" USING btree ("AccountId", "FarmId") | | UX_InventoryItems_Account_Farm_LowerName | CREATE UNIQUE INDEX "UX_InventoryItems_Account_Farm_LowerName" ON public."InventoryItems" USING btree ("AccountId", "FarmId", lower(("Name")::text)) | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_InventoryItems_BusinessRecordTimestamps | CREATE TRIGGER "TR_InventoryItems_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."InventoryItems" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -65,6 +75,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.FeedUsages" { uuid Id @@ -81,6 +93,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.InventoryLots" { uuid Id @@ -95,6 +108,9 @@ erDiagram integer UnitCostCurrencyMinorUnit integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.InventoryMovements" { uuid Id @@ -110,6 +126,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } ``` diff --git a/docs/schema/public.InventoryLots.md b/docs/schema/public.InventoryLots.md index 3b7407c9c..d65efa879 100644 --- a/docs/schema/public.InventoryLots.md +++ b/docs/schema/public.InventoryLots.md @@ -16,6 +16,9 @@ | UnitCostCurrencyMinorUnit | integer | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -28,14 +31,17 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | InventoryLots_AccountId_not_null | n | NOT NULL "AccountId" | +| InventoryLots_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | InventoryLots_Id_not_null | n | NOT NULL "Id" | | InventoryLots_InventoryItemId_not_null | n | NOT NULL "InventoryItemId" | | InventoryLots_QuantityAvailable_not_null | n | NOT NULL "QuantityAvailable" | | InventoryLots_QuantityReceived_not_null | n | NOT NULL "QuantityReceived" | | InventoryLots_ReceivedDate_not_null | n | NOT NULL "ReceivedDate" | +| InventoryLots_Sequence_not_null | n | NOT NULL "Sequence" | | InventoryLots_UnitCostCurrencyCode_not_null | n | NOT NULL "UnitCostCurrencyCode" | | InventoryLots_UnitCostCurrencyMinorUnit_not_null | n | NOT NULL "UnitCostCurrencyMinorUnit" | | InventoryLots_UnitCostMinorUnits_not_null | n | NOT NULL "UnitCostMinorUnits" | +| InventoryLots_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | InventoryLots_Version_not_null | n | NOT NULL "Version" | | FK_InventoryLots_InventoryItems_InventoryItemId | FOREIGN KEY | FOREIGN KEY ("InventoryItemId") REFERENCES "InventoryItems"("Id") ON DELETE RESTRICT | | PK_InventoryLots | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -46,6 +52,13 @@ | ---- | ---------- | | PK_InventoryLots | CREATE UNIQUE INDEX "PK_InventoryLots" ON public."InventoryLots" USING btree ("Id") | | IX_InventoryLots_InventoryItemId_ReceivedDate | CREATE INDEX "IX_InventoryLots_InventoryItemId_ReceivedDate" ON public."InventoryLots" USING btree ("InventoryItemId", "ReceivedDate") | +| IX_InventoryLots_Sequence | CREATE UNIQUE INDEX "IX_InventoryLots_Sequence" ON public."InventoryLots" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_InventoryLots_BusinessRecordTimestamps | CREATE TRIGGER "TR_InventoryLots_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."InventoryLots" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -68,6 +81,9 @@ erDiagram integer UnitCostCurrencyMinorUnit integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.InventoryMovements" { uuid Id @@ -83,6 +99,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } "public.InventoryItems" { uuid Id @@ -96,6 +113,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.InventoryMovements.md b/docs/schema/public.InventoryMovements.md index 0a33bf144..23ab63f88 100644 --- a/docs/schema/public.InventoryMovements.md +++ b/docs/schema/public.InventoryMovements.md @@ -17,6 +17,7 @@ | ReferenceType | varchar(50) | | true | | | | | ReferenceId | uuid | | true | | | | | AccountId | uuid | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -34,6 +35,7 @@ | InventoryMovements_Id_not_null | n | NOT NULL "Id" | | InventoryMovements_InventoryItemId_not_null | n | NOT NULL "InventoryItemId" | | InventoryMovements_QuantityDelta_not_null | n | NOT NULL "QuantityDelta" | +| InventoryMovements_Sequence_not_null | n | NOT NULL "Sequence" | | InventoryMovements_Type_not_null | n | NOT NULL "Type" | | InventoryMovements_Unit_not_null | n | NOT NULL "Unit" | | FK_InventoryMovements_Flocks_FlockId | FOREIGN KEY | FOREIGN KEY ("FlockId") REFERENCES "Flocks"("Id") ON DELETE RESTRICT | @@ -49,6 +51,13 @@ | IX_InventoryMovements_FlockId | CREATE INDEX "IX_InventoryMovements_FlockId" ON public."InventoryMovements" USING btree ("FlockId") | | IX_InventoryMovements_InventoryItemId_Date | CREATE INDEX "IX_InventoryMovements_InventoryItemId_Date" ON public."InventoryMovements" USING btree ("InventoryItemId", "Date") | | IX_InventoryMovements_InventoryLotId | CREATE INDEX "IX_InventoryMovements_InventoryLotId" ON public."InventoryMovements" USING btree ("InventoryLotId") | +| IX_InventoryMovements_Sequence | CREATE UNIQUE INDEX "IX_InventoryMovements_Sequence" ON public."InventoryMovements" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_InventoryMovements_BusinessRecordTimestamps | CREATE TRIGGER "TR_InventoryMovements_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."InventoryMovements" FOR EACH ROW EXECUTE FUNCTION "StampCreatedBusinessRecord"() | ## Relations @@ -73,6 +82,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } "public.InventoryItems" { uuid Id @@ -86,6 +96,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.InventoryLots" { uuid Id @@ -100,6 +112,9 @@ erDiagram integer UnitCostCurrencyMinorUnit integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Flocks" { uuid Id @@ -114,6 +129,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.Payments.md b/docs/schema/public.Payments.md index 4b24b082c..921f72f00 100644 --- a/docs/schema/public.Payments.md +++ b/docs/schema/public.Payments.md @@ -18,6 +18,9 @@ | VoidReason | varchar(500) | | true | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -31,6 +34,7 @@ | ---- | ---- | ---------- | | Payments_AccountId_not_null | n | NOT NULL "AccountId" | | Payments_AmountMinorUnits_not_null | n | NOT NULL "AmountMinorUnits" | +| Payments_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Payments_CurrencyCode_not_null | n | NOT NULL "CurrencyCode" | | Payments_CurrencyMinorUnit_not_null | n | NOT NULL "CurrencyMinorUnit" | | Payments_CustomerId_not_null | n | NOT NULL "CustomerId" | @@ -38,6 +42,8 @@ | Payments_Method_not_null | n | NOT NULL "Method" | | Payments_PaymentDate_not_null | n | NOT NULL "PaymentDate" | | Payments_SalesOrderId_not_null | n | NOT NULL "SalesOrderId" | +| Payments_Sequence_not_null | n | NOT NULL "Sequence" | +| Payments_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Payments_Version_not_null | n | NOT NULL "Version" | | Payments_Voided_not_null | n | NOT NULL "Voided" | | FK_Payments_Customers_CustomerId | FOREIGN KEY | FOREIGN KEY ("CustomerId") REFERENCES "Customers"("Id") ON DELETE RESTRICT | @@ -53,6 +59,13 @@ | IX_Payments_AccountId_SalesOrderId | CREATE INDEX "IX_Payments_AccountId_SalesOrderId" ON public."Payments" USING btree ("AccountId", "SalesOrderId") | | IX_Payments_CustomerId | CREATE INDEX "IX_Payments_CustomerId" ON public."Payments" USING btree ("CustomerId") | | IX_Payments_SalesOrderId | CREATE INDEX "IX_Payments_SalesOrderId" ON public."Payments" USING btree ("SalesOrderId") | +| IX_Payments_Sequence | CREATE UNIQUE INDEX "IX_Payments_Sequence" ON public."Payments" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Payments_BusinessRecordTimestamps | CREATE TRIGGER "TR_Payments_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Payments" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -77,6 +90,9 @@ erDiagram varchar_500_ VoidReason integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.SalesOrders" { uuid Id @@ -92,6 +108,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Customers" { uuid Id @@ -102,6 +121,8 @@ erDiagram varchar_1000_ Note uuid AccountId integer Version + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.ProductEggGradeMappings.md b/docs/schema/public.ProductEggGradeMappings.md index e49894d5a..07ca953b1 100644 --- a/docs/schema/public.ProductEggGradeMappings.md +++ b/docs/schema/public.ProductEggGradeMappings.md @@ -8,6 +8,8 @@ | ProductId | uuid | | false | | [public.Products](public.Products.md) | | | EggGradeId | uuid | | false | | [public.EggGrades](public.EggGrades.md) | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -20,9 +22,11 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | ProductEggGradeMappings_AccountId_not_null | n | NOT NULL "AccountId" | +| ProductEggGradeMappings_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | ProductEggGradeMappings_EggGradeId_not_null | n | NOT NULL "EggGradeId" | | ProductEggGradeMappings_Id_not_null | n | NOT NULL "Id" | | ProductEggGradeMappings_ProductId_not_null | n | NOT NULL "ProductId" | +| ProductEggGradeMappings_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | FK_ProductEggGradeMappings_EggGrades_EggGradeId | FOREIGN KEY | FOREIGN KEY ("EggGradeId") REFERENCES "EggGrades"("Id") ON DELETE RESTRICT | | FK_ProductEggGradeMappings_Products_ProductId | FOREIGN KEY | FOREIGN KEY ("ProductId") REFERENCES "Products"("Id") ON DELETE CASCADE | | PK_ProductEggGradeMappings | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -35,6 +39,12 @@ | IX_ProductEggGradeMappings_EggGradeId | CREATE INDEX "IX_ProductEggGradeMappings_EggGradeId" ON public."ProductEggGradeMappings" USING btree ("EggGradeId") | | IX_ProductEggGradeMappings_ProductId | CREATE UNIQUE INDEX "IX_ProductEggGradeMappings_ProductId" ON public."ProductEggGradeMappings" USING btree ("ProductId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_ProductEggGradeMappings_BusinessRecordTimestamps | CREATE TRIGGER "TR_ProductEggGradeMappings_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."ProductEggGradeMappings" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -48,6 +58,8 @@ erDiagram uuid ProductId FK uuid EggGradeId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Products" { uuid Id @@ -62,6 +74,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggGrades" { uuid Id @@ -74,6 +88,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.Products.md b/docs/schema/public.Products.md index 8e83a3782..0f865c003 100644 --- a/docs/schema/public.Products.md +++ b/docs/schema/public.Products.md @@ -16,6 +16,8 @@ | Active | boolean | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -29,6 +31,7 @@ | ---- | ---- | ---------- | | Products_AccountId_not_null | n | NOT NULL "AccountId" | | Products_Active_not_null | n | NOT NULL "Active" | +| Products_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | Products_CurrencyCode_not_null | n | NOT NULL "CurrencyCode" | | Products_CurrencyMinorUnit_not_null | n | NOT NULL "CurrencyMinorUnit" | | Products_DefaultUnit_not_null | n | NOT NULL "DefaultUnit" | @@ -36,6 +39,7 @@ | Products_Id_not_null | n | NOT NULL "Id" | | Products_Name_not_null | n | NOT NULL "Name" | | Products_ProductType_not_null | n | NOT NULL "ProductType" | +| Products_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | Products_Version_not_null | n | NOT NULL "Version" | | PK_Products | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -46,6 +50,12 @@ | PK_Products | CREATE UNIQUE INDEX "PK_Products" ON public."Products" USING btree ("Id") | | IX_Products_AccountId_LowerName | CREATE UNIQUE INDEX "IX_Products_AccountId_LowerName" ON public."Products" USING btree ("AccountId", lower(("Name")::text)) | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_Products_BusinessRecordTimestamps | CREATE TRIGGER "TR_Products_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."Products" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -67,12 +77,16 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.ProductEggGradeMappings" { uuid Id uuid ProductId FK uuid EggGradeId FK uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrderItems" { uuid Id @@ -90,6 +104,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.SalesOrderAllocations.md b/docs/schema/public.SalesOrderAllocations.md index f4b69d327..3640c17c0 100644 --- a/docs/schema/public.SalesOrderAllocations.md +++ b/docs/schema/public.SalesOrderAllocations.md @@ -11,6 +11,8 @@ | Quantity | integer | | false | | | | | ReleasedOnUtc | timestamp with time zone | | true | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -23,11 +25,13 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | SalesOrderAllocations_AccountId_not_null | n | NOT NULL "AccountId" | +| SalesOrderAllocations_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | SalesOrderAllocations_EggLotId_not_null | n | NOT NULL "EggLotId" | | SalesOrderAllocations_Id_not_null | n | NOT NULL "Id" | | SalesOrderAllocations_Quantity_not_null | n | NOT NULL "Quantity" | | SalesOrderAllocations_SalesOrderId_not_null | n | NOT NULL "SalesOrderId" | | SalesOrderAllocations_SalesOrderItemId_not_null | n | NOT NULL "SalesOrderItemId" | +| SalesOrderAllocations_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | FK_SalesOrderAllocations_SalesOrders_SalesOrderId | FOREIGN KEY | FOREIGN KEY ("SalesOrderId") REFERENCES "SalesOrders"("Id") ON DELETE CASCADE | | FK_SalesOrderAllocations_EggLots_EggLotId | FOREIGN KEY | FOREIGN KEY ("EggLotId") REFERENCES "EggLots"("Id") ON DELETE RESTRICT | | FK_SalesOrderAllocations_SalesOrderItems_SalesOrderItemId | FOREIGN KEY | FOREIGN KEY ("SalesOrderItemId") REFERENCES "SalesOrderItems"("Id") ON DELETE CASCADE | @@ -42,6 +46,12 @@ | IX_SalesOrderAllocations_SalesOrderId | CREATE INDEX "IX_SalesOrderAllocations_SalesOrderId" ON public."SalesOrderAllocations" USING btree ("SalesOrderId") | | IX_SalesOrderAllocations_SalesOrderItemId | CREATE INDEX "IX_SalesOrderAllocations_SalesOrderItemId" ON public."SalesOrderAllocations" USING btree ("SalesOrderItemId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_SalesOrderAllocations_BusinessRecordTimestamps | CREATE TRIGGER "TR_SalesOrderAllocations_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."SalesOrderAllocations" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -59,6 +69,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrders" { uuid Id @@ -74,6 +86,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.SalesOrderItems" { uuid Id @@ -91,6 +106,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggLots" { uuid Id @@ -103,6 +120,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/public.SalesOrderItems.md b/docs/schema/public.SalesOrderItems.md index 24a9b5ebd..802952195 100644 --- a/docs/schema/public.SalesOrderItems.md +++ b/docs/schema/public.SalesOrderItems.md @@ -19,6 +19,8 @@ | AccountId | uuid | | false | | | | | ListUnitPriceMinorUnits | bigint | | true | | | | | ListPriceBasis | varchar(16) | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -32,6 +34,7 @@ | ---- | ---- | ---------- | | SalesOrderItems_AccountId_not_null | n | NOT NULL "AccountId" | | SalesOrderItems_BaseUnitFactor_not_null | n | NOT NULL "BaseUnitFactor" | +| SalesOrderItems_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | SalesOrderItems_EggGradeId_not_null | n | NOT NULL "EggGradeId" | | SalesOrderItems_Id_not_null | n | NOT NULL "Id" | | SalesOrderItems_ListPriceBasis_not_null | n | NOT NULL "ListPriceBasis" | @@ -44,6 +47,7 @@ | SalesOrderItems_UnitPriceCurrencyMinorUnit_not_null | n | NOT NULL "UnitPriceCurrencyMinorUnit" | | SalesOrderItems_UnitPriceMinorUnits_not_null | n | NOT NULL "UnitPriceMinorUnits" | | SalesOrderItems_Unit_not_null | n | NOT NULL "Unit" | +| SalesOrderItems_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | FK_SalesOrderItems_EggGrades_EggGradeId | FOREIGN KEY | FOREIGN KEY ("EggGradeId") REFERENCES "EggGrades"("Id") ON DELETE RESTRICT | | FK_SalesOrderItems_Products_ProductId | FOREIGN KEY | FOREIGN KEY ("ProductId") REFERENCES "Products"("Id") ON DELETE RESTRICT | | FK_SalesOrderItems_SalesOrders_SalesOrderId | FOREIGN KEY | FOREIGN KEY ("SalesOrderId") REFERENCES "SalesOrders"("Id") ON DELETE CASCADE | @@ -58,6 +62,12 @@ | IX_SalesOrderItems_ProductId | CREATE INDEX "IX_SalesOrderItems_ProductId" ON public."SalesOrderItems" USING btree ("ProductId") | | IX_SalesOrderItems_SalesOrderId | CREATE INDEX "IX_SalesOrderItems_SalesOrderId" ON public."SalesOrderItems" USING btree ("SalesOrderId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_SalesOrderItems_BusinessRecordTimestamps | CREATE TRIGGER "TR_SalesOrderItems_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."SalesOrderItems" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | + ## Relations ```mermaid @@ -84,6 +94,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrderAllocations" { uuid Id @@ -93,6 +105,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrders" { uuid Id @@ -108,6 +122,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Products" { uuid Id @@ -122,6 +139,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggGrades" { uuid Id @@ -134,6 +153,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.SalesOrders.md b/docs/schema/public.SalesOrders.md index a5bbed9d7..8519cb3c1 100644 --- a/docs/schema/public.SalesOrders.md +++ b/docs/schema/public.SalesOrders.md @@ -17,6 +17,9 @@ | AccountId | uuid | | false | | | | | DiscountReasonCode | varchar(32) | | true | | | | | DiscountReasonNote | varchar(500) | | true | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -29,14 +32,17 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | SalesOrders_AccountId_not_null | n | NOT NULL "AccountId" | +| SalesOrders_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | SalesOrders_CustomerId_not_null | n | NOT NULL "CustomerId" | | SalesOrders_Id_not_null | n | NOT NULL "Id" | | SalesOrders_OrderDate_not_null | n | NOT NULL "OrderDate" | | SalesOrders_ReferenceNumber_not_null | n | NOT NULL "ReferenceNumber" | +| SalesOrders_Sequence_not_null | n | NOT NULL "Sequence" | | SalesOrders_Status_not_null | n | NOT NULL "Status" | | SalesOrders_TotalCurrencyCode_not_null | n | NOT NULL "TotalCurrencyCode" | | SalesOrders_TotalCurrencyMinorUnit_not_null | n | NOT NULL "TotalCurrencyMinorUnit" | | SalesOrders_TotalMinorUnits_not_null | n | NOT NULL "TotalMinorUnits" | +| SalesOrders_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | SalesOrders_Version_not_null | n | NOT NULL "Version" | | FK_SalesOrders_Customers_CustomerId | FOREIGN KEY | FOREIGN KEY ("CustomerId") REFERENCES "Customers"("Id") ON DELETE RESTRICT | | PK_SalesOrders | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -48,6 +54,13 @@ | PK_SalesOrders | CREATE UNIQUE INDEX "PK_SalesOrders" ON public."SalesOrders" USING btree ("Id") | | IX_SalesOrders_AccountId_ReferenceNumber | CREATE UNIQUE INDEX "IX_SalesOrders_AccountId_ReferenceNumber" ON public."SalesOrders" USING btree ("AccountId", "ReferenceNumber") | | IX_SalesOrders_CustomerId | CREATE INDEX "IX_SalesOrders_CustomerId" ON public."SalesOrders" USING btree ("CustomerId") | +| IX_SalesOrders_Sequence | CREATE UNIQUE INDEX "IX_SalesOrders_Sequence" ON public."SalesOrders" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_SalesOrders_BusinessRecordTimestamps | CREATE TRIGGER "TR_SalesOrders_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."SalesOrders" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -73,6 +86,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Payments" { uuid Id @@ -89,6 +105,9 @@ erDiagram varchar_500_ VoidReason integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.SalesOrderItems" { uuid Id @@ -106,6 +125,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrderAllocations" { uuid Id @@ -115,6 +136,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Customers" { uuid Id @@ -125,6 +148,8 @@ erDiagram varchar_1000_ Note uuid AccountId integer Version + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/public.UserRoleAssignments.md b/docs/schema/public.UserRoleAssignments.md index 968c81af4..003fe5c09 100644 --- a/docs/schema/public.UserRoleAssignments.md +++ b/docs/schema/public.UserRoleAssignments.md @@ -10,6 +10,7 @@ | HouseId | uuid | | true | | | | | FlockId | uuid | | true | | | | | AccountId | uuid | | false | | | | +| CreatedAtUtc | timestamp with time zone | | false | | | | ## Viewpoints @@ -22,6 +23,7 @@ | Name | Type | Definition | | ---- | ---- | ---------- | | UserRoleAssignments_AccountId_not_null | n | NOT NULL "AccountId" | +| UserRoleAssignments_CreatedAtUtc_not_null | n | NOT NULL "CreatedAtUtc" | | UserRoleAssignments_Id_not_null | n | NOT NULL "Id" | | UserRoleAssignments_UserId_not_null | n | NOT NULL "UserId" | | PK_UserRoleAssignments | PRIMARY KEY | PRIMARY KEY ("Id") | @@ -34,6 +36,12 @@ | IX_UserRoleAssignments_AccountId_UserId | CREATE INDEX "IX_UserRoleAssignments_AccountId_UserId" ON public."UserRoleAssignments" USING btree ("AccountId", "UserId") | | IX_UserRoleAssignments_UserId_FlockId | CREATE UNIQUE INDEX "IX_UserRoleAssignments_UserId_FlockId" ON public."UserRoleAssignments" USING btree ("UserId", "FlockId") | +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_UserRoleAssignments_BusinessRecordTimestamps | CREATE TRIGGER "TR_UserRoleAssignments_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."UserRoleAssignments" FOR EACH ROW EXECUTE FUNCTION "StampCreatedBusinessRecord"() | + ## Relations ```mermaid @@ -47,6 +55,7 @@ erDiagram uuid HouseId uuid FlockId uuid AccountId + timestamp_with_time_zone CreatedAtUtc } ``` diff --git a/docs/schema/public.WaterUsages.md b/docs/schema/public.WaterUsages.md index 65fb9e5cc..e453442d6 100644 --- a/docs/schema/public.WaterUsages.md +++ b/docs/schema/public.WaterUsages.md @@ -17,6 +17,8 @@ | CreatedAtUtc | timestamp with time zone | | false | | | | | Version | integer | | false | | | | | AccountId | uuid | | false | | | | +| UpdatedAtUtc | timestamp with time zone | | false | | | | +| Sequence | bigint | | false | | | | ## Viewpoints @@ -34,8 +36,10 @@ | WaterUsages_FlockId_not_null | n | NOT NULL "FlockId" | | WaterUsages_Id_not_null | n | NOT NULL "Id" | | WaterUsages_Quantity_not_null | n | NOT NULL "Quantity" | +| WaterUsages_Sequence_not_null | n | NOT NULL "Sequence" | | WaterUsages_Source_not_null | n | NOT NULL "Source" | | WaterUsages_Unit_not_null | n | NOT NULL "Unit" | +| WaterUsages_UpdatedAtUtc_not_null | n | NOT NULL "UpdatedAtUtc" | | WaterUsages_Version_not_null | n | NOT NULL "Version" | | FK_WaterUsages_DailyEntries_DailyEntryId | FOREIGN KEY | FOREIGN KEY ("DailyEntryId") REFERENCES "DailyEntries"("Id") ON DELETE RESTRICT | | FK_WaterUsages_Flocks_FlockId | FOREIGN KEY | FOREIGN KEY ("FlockId") REFERENCES "Flocks"("Id") ON DELETE RESTRICT | @@ -48,6 +52,13 @@ | PK_WaterUsages | CREATE UNIQUE INDEX "PK_WaterUsages" ON public."WaterUsages" USING btree ("Id") | | IX_WaterUsages_DailyEntryId | CREATE INDEX "IX_WaterUsages_DailyEntryId" ON public."WaterUsages" USING btree ("DailyEntryId") | | IX_WaterUsages_FlockId_Date | CREATE INDEX "IX_WaterUsages_FlockId_Date" ON public."WaterUsages" USING btree ("FlockId", "Date") | +| IX_WaterUsages_Sequence | CREATE UNIQUE INDEX "IX_WaterUsages_Sequence" ON public."WaterUsages" USING btree ("Sequence") | + +## Triggers + +| Name | Definition | +| ---- | ---------- | +| TR_WaterUsages_BusinessRecordTimestamps | CREATE TRIGGER "TR_WaterUsages_BusinessRecordTimestamps" BEFORE INSERT OR UPDATE ON public."WaterUsages" FOR EACH ROW EXECUTE FUNCTION "StampMutableBusinessRecord"() | ## Relations @@ -71,6 +82,8 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Flocks" { uuid Id @@ -85,6 +98,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntries" { uuid Id @@ -106,6 +121,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } ``` diff --git a/docs/schema/viewpoint-0.md b/docs/schema/viewpoint-0.md index 848ff029c..08aee6cc0 100644 --- a/docs/schema/viewpoint-0.md +++ b/docs/schema/viewpoint-0.md @@ -8,16 +8,16 @@ The daily egg loop — flocks, daily entries, grading, lots, movements. | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | -| [public.DailyEntries](public.DailyEntries.md) | 19 | | BASE TABLE | -| [public.EggGrades](public.EggGrades.md) | 10 | | BASE TABLE | -| [public.EggUnitConversions](public.EggUnitConversions.md) | 6 | | BASE TABLE | -| [public.Flocks](public.Flocks.md) | 12 | | BASE TABLE | -| [public.DailyEntryGrades](public.DailyEntryGrades.md) | 5 | | BASE TABLE | -| [public.EggLots](public.EggLots.md) | 10 | | BASE TABLE | -| [public.BirdMovements](public.BirdMovements.md) | 8 | | BASE TABLE | -| [public.WaterUsages](public.WaterUsages.md) | 13 | | BASE TABLE | -| [public.FeedUsages](public.FeedUsages.md) | 14 | | BASE TABLE | -| [public.EggInventoryMovements](public.EggInventoryMovements.md) | 9 | | BASE TABLE | +| [public.DailyEntries](public.DailyEntries.md) | 22 | | BASE TABLE | +| [public.EggGrades](public.EggGrades.md) | 12 | | BASE TABLE | +| [public.EggUnitConversions](public.EggUnitConversions.md) | 8 | | BASE TABLE | +| [public.Flocks](public.Flocks.md) | 14 | | BASE TABLE | +| [public.DailyEntryGrades](public.DailyEntryGrades.md) | 7 | | BASE TABLE | +| [public.EggLots](public.EggLots.md) | 13 | | BASE TABLE | +| [public.BirdMovements](public.BirdMovements.md) | 10 | | BASE TABLE | +| [public.WaterUsages](public.WaterUsages.md) | 15 | | BASE TABLE | +| [public.FeedUsages](public.FeedUsages.md) | 15 | | BASE TABLE | +| [public.EggInventoryMovements](public.EggInventoryMovements.md) | 10 | | BASE TABLE | ## Relations @@ -55,6 +55,9 @@ erDiagram timestamp_with_time_zone LockedAtUtc integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.EggGrades" { uuid Id @@ -67,6 +70,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggUnitConversions" { uuid Id @@ -75,6 +80,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Flocks" { uuid Id @@ -89,6 +96,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.DailyEntryGrades" { uuid Id @@ -96,6 +105,8 @@ erDiagram uuid EggGradeId integer Quantity uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggLots" { uuid Id @@ -108,6 +119,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.BirdMovements" { uuid Id @@ -118,6 +132,8 @@ erDiagram varchar_500_ Note uuid DailyEntryId uuid AccountId + timestamp_with_time_zone CreatedAtUtc + bigint Sequence } "public.WaterUsages" { uuid Id @@ -133,6 +149,8 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.FeedUsages" { uuid Id @@ -149,6 +167,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.EggInventoryMovements" { uuid Id @@ -160,6 +179,7 @@ erDiagram varchar_500_ Reason timestamp_with_time_zone CreatedAtUtc uuid AccountId + bigint Sequence } ``` diff --git a/docs/schema/viewpoint-1.md b/docs/schema/viewpoint-1.md index 98622c8b9..5142e5fd5 100644 --- a/docs/schema/viewpoint-1.md +++ b/docs/schema/viewpoint-1.md @@ -8,11 +8,11 @@ Purchasable supplies, FIFO lots, and the movement ledger. | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | -| [public.Flocks](public.Flocks.md) | 12 | | BASE TABLE | -| [public.InventoryItems](public.InventoryItems.md) | 11 | | BASE TABLE | -| [public.FeedUsages](public.FeedUsages.md) | 14 | | BASE TABLE | -| [public.InventoryLots](public.InventoryLots.md) | 12 | | BASE TABLE | -| [public.InventoryMovements](public.InventoryMovements.md) | 13 | | BASE TABLE | +| [public.Flocks](public.Flocks.md) | 14 | | BASE TABLE | +| [public.InventoryItems](public.InventoryItems.md) | 13 | | BASE TABLE | +| [public.FeedUsages](public.FeedUsages.md) | 15 | | BASE TABLE | +| [public.InventoryLots](public.InventoryLots.md) | 15 | | BASE TABLE | +| [public.InventoryMovements](public.InventoryMovements.md) | 14 | | BASE TABLE | ## Relations @@ -39,6 +39,8 @@ erDiagram date ArchivedOn integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.InventoryItems" { uuid Id @@ -52,6 +54,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.FeedUsages" { uuid Id @@ -68,6 +72,7 @@ erDiagram timestamp_with_time_zone CreatedAtUtc integer Version uuid AccountId + bigint Sequence } "public.InventoryLots" { uuid Id @@ -82,6 +87,9 @@ erDiagram integer UnitCostCurrencyMinorUnit integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.InventoryMovements" { uuid Id @@ -97,6 +105,7 @@ erDiagram varchar_50_ ReferenceType uuid ReferenceId uuid AccountId + bigint Sequence } ``` diff --git a/docs/schema/viewpoint-2.md b/docs/schema/viewpoint-2.md index 1b8154f64..996b24f47 100644 --- a/docs/schema/viewpoint-2.md +++ b/docs/schema/viewpoint-2.md @@ -8,17 +8,17 @@ Customers, orders, FIFO allocations, payments, expenses, products. | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | -| [public.Customers](public.Customers.md) | 8 | | BASE TABLE | -| [public.EggGrades](public.EggGrades.md) | 10 | | BASE TABLE | -| [public.ExpenseCategories](public.ExpenseCategories.md) | 6 | | BASE TABLE | -| [public.Products](public.Products.md) | 12 | | BASE TABLE | -| [public.SalesOrders](public.SalesOrders.md) | 13 | | BASE TABLE | -| [public.EggLots](public.EggLots.md) | 10 | | BASE TABLE | -| [public.Expenses](public.Expenses.md) | 12 | | BASE TABLE | -| [public.ProductEggGradeMappings](public.ProductEggGradeMappings.md) | 4 | | BASE TABLE | -| [public.Payments](public.Payments.md) | 14 | | BASE TABLE | -| [public.SalesOrderItems](public.SalesOrderItems.md) | 15 | | BASE TABLE | -| [public.SalesOrderAllocations](public.SalesOrderAllocations.md) | 7 | | BASE TABLE | +| [public.Customers](public.Customers.md) | 10 | | BASE TABLE | +| [public.EggGrades](public.EggGrades.md) | 12 | | BASE TABLE | +| [public.ExpenseCategories](public.ExpenseCategories.md) | 8 | | BASE TABLE | +| [public.Products](public.Products.md) | 14 | | BASE TABLE | +| [public.SalesOrders](public.SalesOrders.md) | 16 | | BASE TABLE | +| [public.EggLots](public.EggLots.md) | 13 | | BASE TABLE | +| [public.Expenses](public.Expenses.md) | 15 | | BASE TABLE | +| [public.ProductEggGradeMappings](public.ProductEggGradeMappings.md) | 6 | | BASE TABLE | +| [public.Payments](public.Payments.md) | 17 | | BASE TABLE | +| [public.SalesOrderItems](public.SalesOrderItems.md) | 17 | | BASE TABLE | +| [public.SalesOrderAllocations](public.SalesOrderAllocations.md) | 9 | | BASE TABLE | ## Relations @@ -48,6 +48,8 @@ erDiagram varchar_1000_ Note uuid AccountId integer Version + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.EggGrades" { uuid Id @@ -60,6 +62,8 @@ erDiagram varchar_16_ DailyEntryKind integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.ExpenseCategories" { uuid Id @@ -68,6 +72,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Products" { uuid Id @@ -82,6 +88,8 @@ erDiagram boolean Active integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrders" { uuid Id @@ -97,6 +105,9 @@ erDiagram uuid AccountId varchar_32_ DiscountReasonCode varchar_500_ DiscountReasonNote + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.EggLots" { uuid Id @@ -109,6 +120,9 @@ erDiagram date RestrictedUntil integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.Expenses" { uuid Id @@ -123,12 +137,17 @@ erDiagram varchar_500_ Note integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.ProductEggGradeMappings" { uuid Id uuid ProductId uuid EggGradeId uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.Payments" { uuid Id @@ -145,6 +164,9 @@ erDiagram varchar_500_ VoidReason integer Version uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc + bigint Sequence } "public.SalesOrderItems" { uuid Id @@ -162,6 +184,8 @@ erDiagram uuid AccountId bigint ListUnitPriceMinorUnits varchar_16_ ListPriceBasis + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.SalesOrderAllocations" { uuid Id @@ -171,6 +195,8 @@ erDiagram integer Quantity timestamp_with_time_zone ReleasedOnUtc uuid AccountId + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } ``` diff --git a/docs/schema/viewpoint-3.md b/docs/schema/viewpoint-3.md index 4f876a049..0a94db803 100644 --- a/docs/schema/viewpoint-3.md +++ b/docs/schema/viewpoint-3.md @@ -9,9 +9,9 @@ ASP.NET Identity, refresh tokens, per-flock role assignments. | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | | [public.AspNetRoles](public.AspNetRoles.md) | 4 | | BASE TABLE | -| [public.AspNetUsers](public.AspNetUsers.md) | 24 | | BASE TABLE | +| [public.AspNetUsers](public.AspNetUsers.md) | 26 | | BASE TABLE | | [public.refresh_tokens](public.refresh_tokens.md) | 11 | | BASE TABLE | -| [public.UserRoleAssignments](public.UserRoleAssignments.md) | 6 | | BASE TABLE | +| [public.UserRoleAssignments](public.UserRoleAssignments.md) | 7 | | BASE TABLE | | [public.AspNetRoleClaims](public.AspNetRoleClaims.md) | 4 | | BASE TABLE | | [public.AspNetUserClaims](public.AspNetUserClaims.md) | 4 | | BASE TABLE | | [public.AspNetUserLogins](public.AspNetUserLogins.md) | 4 | | BASE TABLE | @@ -62,6 +62,8 @@ erDiagram uuid DisabledBy varchar_16_ PreferredStepperUnit integer StepUpLogoutEpoch + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.refresh_tokens" { uuid Id @@ -83,6 +85,7 @@ erDiagram uuid HouseId uuid FlockId uuid AccountId + timestamp_with_time_zone CreatedAtUtc } "public.AspNetRoleClaims" { integer Id diff --git a/docs/schema/viewpoint-4.md b/docs/schema/viewpoint-4.md index 774572110..f3cbd37a2 100644 --- a/docs/schema/viewpoint-4.md +++ b/docs/schema/viewpoint-4.md @@ -8,10 +8,10 @@ Tenancy root, audit, jobs, idempotency, seeding bookkeeping. | Name | Columns | Comment | Type | | ---- | ------- | ------- | ---- | -| [public.Accounts](public.Accounts.md) | 19 | | BASE TABLE | +| [public.Accounts](public.Accounts.md) | 21 | | BASE TABLE | | [public.AuditEvents](public.AuditEvents.md) | 11 | | BASE TABLE | | [public.durable_jobs](public.durable_jobs.md) | 9 | | BASE TABLE | -| [public.FarmLogos](public.FarmLogos.md) | 18 | | BASE TABLE | +| [public.FarmLogos](public.FarmLogos.md) | 20 | | BASE TABLE | | [public.idempotency_records](public.idempotency_records.md) | 13 | | BASE TABLE | | [public.simulation_seed_state](public.simulation_seed_state.md) | 3 | | BASE TABLE | @@ -41,6 +41,8 @@ erDiagram varchar_32_ Slug varchar_24_ WorkerSaleAllocationPolicy integer MaxDiscountBasisPoints + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.AuditEvents" { uuid Id @@ -85,6 +87,8 @@ erDiagram integer BannerHeight timestamp_with_time_zone BannerUpdatedAt integer BannerWidth + timestamp_with_time_zone CreatedAtUtc + timestamp_with_time_zone UpdatedAtUtc } "public.idempotency_records" { uuid Id diff --git a/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs index 4df00042e..31cd7dc02 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs @@ -24,7 +24,6 @@ public sealed class AdjustDailyEntryHandler( IBirdMovementRepository birdMovements, IFlockRepository flocks, IEggInventoryMovementRepository eggMovements, - IClock clock, IUnitOfWork unitOfWork, IAuditWriter audit, ILogger logger) @@ -183,7 +182,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => if (availableDelta != 0) await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lots[i].Id, EggMovementType.Adjustment, - availableDelta, nameof(DailyEntry), entry.Id, clock.UtcNow, + availableDelta, nameof(DailyEntry), entry.Id, reason: command.Reason), transactionCt); } } @@ -210,7 +209,7 @@ await eggMovements.AddAsync(EggInventoryMovement.Create( if (quantity > 0) await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, newLot.Id, EggMovementType.Adjustment, - quantity, nameof(DailyEntry), entry.Id, clock.UtcNow, + quantity, nameof(DailyEntry), entry.Id, reason: command.Reason), transactionCt); } diff --git a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs index 4da8a691b..5b3bc3ffb 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs @@ -25,7 +25,6 @@ public sealed class SubmitDailyEntryHandler( IEggInventoryMovementRepository eggMovements, IFlockRepository flocks, IFlockScopeGuard flockScope, - IClock clock, IAuditWriter audit, IUnitOfWork unitOfWork, ILogger logger) @@ -94,7 +93,7 @@ public async Task> HandleAsync( // QuantityAvailable always equals the sum of movements. await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Production, - line.Quantity, nameof(DailyEntry), entry.Id, clock.UtcNow), ct); + line.Quantity, nameof(DailyEntry), entry.Id), ct); } // #396 — the counter-backed lots, in the same transaction as the manual @@ -123,7 +122,7 @@ await eggMovements.AddAsync(EggInventoryMovement.Create( await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Production, - quantity, nameof(DailyEntry), entry.Id, clock.UtcNow), ct); + quantity, nameof(DailyEntry), entry.Id), ct); } // The day's mortality becomes a ledger row so the flock's current count diff --git a/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs b/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs index bbd9d830e..823afcaf5 100644 --- a/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs +++ b/src/Cluckwork.Application/Features/DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs @@ -23,7 +23,6 @@ public sealed class VoidDailyEntryHandler( IBirdMovementRepository birdMovements, IEggInventoryMovementRepository eggMovements, IFlockRepository flocks, - IClock clock, IUnitOfWork unitOfWork, IAuditWriter audit, ILogger logger) @@ -96,7 +95,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => if (vacated > 0) await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Void, - -vacated, nameof(DailyEntry), entry.Id, clock.UtcNow, + -vacated, nameof(DailyEntry), entry.Id, reason: command.Reason), transactionCt); } diff --git a/src/Cluckwork.Application/Features/EggLots/RecordEggLotMovement/RecordEggLotMovementHandler.cs b/src/Cluckwork.Application/Features/EggLots/RecordEggLotMovement/RecordEggLotMovementHandler.cs index 52051ca98..a0ba566c5 100644 --- a/src/Cluckwork.Application/Features/EggLots/RecordEggLotMovement/RecordEggLotMovementHandler.cs +++ b/src/Cluckwork.Application/Features/EggLots/RecordEggLotMovement/RecordEggLotMovementHandler.cs @@ -15,7 +15,6 @@ public sealed class RecordEggLotMovementHandler( IEggLotRepository lots, IEggInventoryMovementRepository movements, IUnitOfWork unitOfWork, - IClock clock, IAuditWriter audit, ILogger logger) { @@ -31,7 +30,7 @@ public async Task> HandleAsync( "EggLotMovement.MovementType.Allowed", "Type must be 'Discard', 'InternalUse' or 'Reconciliation'.")).LogFailure(logger, "RecordEggLotMovement"); - Result? outcome = null; + Result<(EggInventoryMovement Movement, EggLot Lot)>? outcome = null; await unitOfWork.ExecuteInTransactionAsync(async transactionCt => { @@ -39,7 +38,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => .SingleOrDefault(); if (lot is null) { - outcome = Result.Failure( + outcome = Result.Failure<(EggInventoryMovement, EggLot)>( Error.NotFound(nameof(EggLot), command.EggLotId)); return false; } @@ -60,7 +59,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => .Sum(m => (long)m.QuantityDelta); if (command.QuantityDelta > writtenOff) { - outcome = Result.Failure(Error.Domain( + outcome = Result.Failure<(EggInventoryMovement, EggLot)>(Error.Domain( "EggLot.ReconcileExceedsWrittenOff", $"Only {writtenOff} eggs have been written off from this lot; a recount above that is a daily-entry adjustment or a sale void.")); return false; @@ -70,30 +69,34 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => var adjust = lot.AdjustAvailable(command.QuantityDelta); if (adjust.IsFailure) { - outcome = Result.Failure(adjust.Error); + outcome = Result.Failure<(EggInventoryMovement, EggLot)>(adjust.Error); return false; } var movement = EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, type, command.QuantityDelta, - nameof(EggLot), lot.Id, clock.UtcNow, command.Reason); + nameof(EggLot), lot.Id, command.Reason); await movements.AddAsync(movement, transactionCt); // Same transaction as the change (#93). await audit.WriteAsync(AuditActions.EggLotMovement, nameof(EggLot), lot.Id, command.Reason, new { command.MovementType, command.QuantityDelta }, transactionCt); - outcome = Result.Success(new RecordEggLotMovementResult( - movement.Id, lot.Id, movement.MovementType.ToString(), movement.QuantityDelta, - movement.Reason, movement.CreatedAtUtc, lot.QuantityAvailable, lot.Version)); + outcome = Result.Success((movement, lot)); return true; }, ct); - if (outcome!.IsSuccess) - logger.LogInformation( - "Egg lot movement {EggInventoryMovementId} recorded: {QuantityDelta} on lot {EggLotId} ({MovementType})", - outcome.Value.MovementId, command.QuantityDelta, command.EggLotId, command.MovementType); - return outcome.LogFailure(logger, "RecordEggLotMovement"); + if (outcome!.IsFailure) + return Result.Failure(outcome.Error) + .LogFailure(logger, "RecordEggLotMovement"); + + var (recordedMovement, updatedLot) = outcome.Value; + logger.LogInformation( + "Egg lot movement {EggInventoryMovementId} recorded: {QuantityDelta} on lot {EggLotId} ({MovementType})", + recordedMovement.Id, command.QuantityDelta, command.EggLotId, command.MovementType); + return Result.Success(new RecordEggLotMovementResult( + recordedMovement.Id, updatedLot.Id, recordedMovement.MovementType.ToString(), recordedMovement.QuantityDelta, + recordedMovement.Reason, recordedMovement.CreatedAtUtc, updatedLot.QuantityAvailable, updatedLot.Version)); } } diff --git a/src/Cluckwork.Application/Features/Eggs/IEggInventoryMovementRepository.cs b/src/Cluckwork.Application/Features/Eggs/IEggInventoryMovementRepository.cs index fca8a1b24..eb45d54a1 100644 --- a/src/Cluckwork.Application/Features/Eggs/IEggInventoryMovementRepository.cs +++ b/src/Cluckwork.Application/Features/Eggs/IEggInventoryMovementRepository.cs @@ -6,6 +6,6 @@ public interface IEggInventoryMovementRepository { Task AddAsync(EggInventoryMovement movement, CancellationToken ct = default); Task AddRangeAsync(IEnumerable movements, CancellationToken ct = default); - /// A lot's movements, newest first (CreatedAtUtc, then Id). + /// A lot's movements, newest first (CreatedAtUtc, then Sequence). Task> ListByLotAsync(Guid eggLotId, CancellationToken ct = default); } diff --git a/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs b/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs index cd6c8b41d..0be7d0002 100644 --- a/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/RecordAdjustment/RecordAdjustmentHandler.cs @@ -14,7 +14,6 @@ public sealed class RecordAdjustmentHandler( IInventoryLotRepository lots, IInventoryMovementRepository movements, IUnitOfWork unitOfWork, - IClock clock, IFarmClock farmClock, IAuditWriter audit, ILogger logger) @@ -63,7 +62,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => : InventoryMovementType.Adjustment; var movement = InventoryMovement.Create( accountId, item.Id, lot.Id, command.Date, type, - command.QuantityDelta, item.Unit, clock.UtcNow, + command.QuantityDelta, item.Unit, flockId: null, note: command.Reason); await movements.AddAsync(movement, transactionCt); diff --git a/src/Cluckwork.Application/Features/Inventory/RecordFeedUsage/RecordFeedUsageHandler.cs b/src/Cluckwork.Application/Features/Inventory/RecordFeedUsage/RecordFeedUsageHandler.cs index ef88501ea..efa295402 100644 --- a/src/Cluckwork.Application/Features/Inventory/RecordFeedUsage/RecordFeedUsageHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/RecordFeedUsage/RecordFeedUsageHandler.cs @@ -22,7 +22,6 @@ public sealed class RecordFeedUsageHandler( IFlockScopeGuard flockScope, Cluckwork.Application.Features.DailyEntries.IDailyEntryRepository dailyEntries, IUnitOfWork unitOfWork, - IClock clock, IFarmClock farmClock, ILogger logger) { @@ -117,7 +116,6 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => return false; } - var createdAt = clock.UtcNow; var usageId = Guid.NewGuid(); var remaining = command.Quantity; decimal costMinorUnits = 0; @@ -145,8 +143,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => await movements.AddAsync(InventoryMovement.Create( accountId, item.Id, lot.Id, command.Date, - InventoryMovementType.Usage, -take, item.Unit, - createdAt, flockId: flock.Id, note: command.Note, + InventoryMovementType.Usage, -take, item.Unit, flockId: flock.Id, note: command.Note, referenceType: nameof(FeedUsage), referenceId: usageId), transactionCt); remaining -= take; @@ -162,7 +159,7 @@ await movements.AddAsync(InventoryMovement.Create( var estimatedCost = new Money((long)costMinorUnits, currencyCode!, currencyMinorUnit); var usage = FeedUsage.Create( usageId, accountId, flock.Id, item.Id, - command.Date, command.Quantity, item.Unit, estimatedCost, createdAt, command.Note, + command.Date, command.Quantity, item.Unit, estimatedCost, command.Note, dailyEntryId); await usages.AddAsync(usage, transactionCt); diff --git a/src/Cluckwork.Application/Features/Inventory/RecordPurchase/RecordPurchaseHandler.cs b/src/Cluckwork.Application/Features/Inventory/RecordPurchase/RecordPurchaseHandler.cs index d16ed8492..ab20c55bf 100644 --- a/src/Cluckwork.Application/Features/Inventory/RecordPurchase/RecordPurchaseHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/RecordPurchase/RecordPurchaseHandler.cs @@ -15,7 +15,6 @@ public sealed class RecordPurchaseHandler( IInventoryMovementRepository movements, IAccountRepository accounts, IUnitOfWork unitOfWork, - IClock clock, IFarmClock farmClock, ILogger logger) { @@ -83,8 +82,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => var movement = InventoryMovement.Create( accountId, item.Id, lot.Id, command.ReceivedDate, - InventoryMovementType.Purchase, command.Quantity, item.Unit, - clock.UtcNow, flockId: null, note: command.Note); + InventoryMovementType.Purchase, command.Quantity, item.Unit, flockId: null, note: command.Note); await lots.AddAsync(lot, transactionCt); await movements.AddAsync(movement, transactionCt); diff --git a/src/Cluckwork.Application/Features/Inventory/RecordWaterUsage/RecordWaterUsageHandler.cs b/src/Cluckwork.Application/Features/Inventory/RecordWaterUsage/RecordWaterUsageHandler.cs index e2a71f857..ecd65fe81 100644 --- a/src/Cluckwork.Application/Features/Inventory/RecordWaterUsage/RecordWaterUsageHandler.cs +++ b/src/Cluckwork.Application/Features/Inventory/RecordWaterUsage/RecordWaterUsageHandler.cs @@ -14,7 +14,6 @@ public sealed class RecordWaterUsageHandler( IFlockScopeGuard flockScope, Cluckwork.Application.Features.DailyEntries.IDailyEntryRepository dailyEntries, IUnitOfWork unitOfWork, - IClock clock, IFarmClock farmClock, ILogger logger) { @@ -56,7 +55,7 @@ public async Task> HandleAsync( var usage = WaterUsage.Create( Guid.NewGuid(), accountId, flock.Id, command.Date, quantity, command.Unit ?? "L", source, - command.MeterStart, command.MeterEnd, clock.UtcNow, command.Note, + command.MeterStart, command.MeterEnd, command.Note, dailyEntryId); await waterUsages.AddAsync(usage, ct); diff --git a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs index 5c61cee8f..d68db81c2 100644 --- a/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/ConfirmSale/ConfirmSaleHandler.cs @@ -311,7 +311,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => // stay distinguishable (codex #102). await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Sale, - -draw.Quantity, nameof(SalesOrderAllocation), allocation.Id, clock.UtcNow), transactionCt); + -draw.Quantity, nameof(SalesOrderAllocation), allocation.Id), transactionCt); } var confirmResult = order.Confirm(discountReasonCode, command.DiscountReasonNote); diff --git a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs index cd42af0a6..992dfce46 100644 --- a/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs +++ b/src/Cluckwork.Application/Features/Sales/VoidSale/VoidSaleHandler.cs @@ -113,7 +113,7 @@ await unitOfWork.ExecuteInTransactionAsync(async transactionCt => // Void movement, same transaction as the restore. await eggMovements.AddAsync(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lot.Id, EggMovementType.Void, - perLot[lot.Id], nameof(SalesOrder), order.Id, clock.UtcNow, + perLot[lot.Id], nameof(SalesOrder), order.Id, reason: command.Reason), transactionCt); } diff --git a/src/Cluckwork.Domain/Accounts/Account.cs b/src/Cluckwork.Domain/Accounts/Account.cs index fdb59954d..637563e10 100644 --- a/src/Cluckwork.Domain/Accounts/Account.cs +++ b/src/Cluckwork.Domain/Accounts/Account.cs @@ -9,7 +9,7 @@ namespace Cluckwork.Domain.Accounts; // so for the single-farm Phase 1 the §4.5 localization fields live here on the // account — the same row IFarmClock already reads TimeZoneId from (#35) and the // same currency financial rows already snapshot at creation (§4.6). -public sealed class Account : AggregateRoot +public sealed class Account : AggregateRoot, IMutableRecord { public const int MaxNameLength = 120; public const int MaxLocaleLength = 32; @@ -92,6 +92,8 @@ MaxDiscountBasisPoints is { } basisPoints ? DiscountCeiling.FromBasisPoints(basisPoints) : null; + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Account() { } diff --git a/src/Cluckwork.Domain/Accounts/FarmLogo.cs b/src/Cluckwork.Domain/Accounts/FarmLogo.cs index b982f9a3c..53390550a 100644 --- a/src/Cluckwork.Domain/Accounts/FarmLogo.cs +++ b/src/Cluckwork.Domain/Accounts/FarmLogo.cs @@ -30,7 +30,7 @@ namespace Cluckwork.Domain.Accounts; // migration, own EF configuration, own repository) was judged not worth it for // one extra image. The logo and banner sides are otherwise fully independent — // see HasLogo/HasBanner and Replace/ReplaceBanner/ClearLogo/ClearBanner below. -public sealed class FarmLogo : AggregateRoot +public sealed class FarmLogo : AggregateRoot, IMutableRecord { public Guid FarmId { get; private set; } @@ -112,6 +112,8 @@ public sealed class FarmLogo : AggregateRoot // No client sends this: a raw-body PUT has no base version to carry. It is // server-side only, and the loser gets the 409 that Program.cs already maps // from DbUpdateConcurrencyException. + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private FarmLogo() { } diff --git a/src/Cluckwork.Domain/Accounts/UserRoleAssignment.cs b/src/Cluckwork.Domain/Accounts/UserRoleAssignment.cs index 690272789..c2e7a258a 100644 --- a/src/Cluckwork.Domain/Accounts/UserRoleAssignment.cs +++ b/src/Cluckwork.Domain/Accounts/UserRoleAssignment.cs @@ -6,12 +6,13 @@ namespace Cluckwork.Domain.Accounts; // become real (spec defers them). A worker with NO assignment rows keeps // account-wide production access (grandfathering #73 workers); adding the // first row narrows them to the assigned flocks. -public sealed class UserRoleAssignment : AggregateRoot +public sealed class UserRoleAssignment : AggregateRoot, ICreatedRecord { public Guid UserId { get; private set; } public Guid? FarmId { get; private set; } public Guid? HouseId { get; private set; } public Guid? FlockId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } private UserRoleAssignment() { } diff --git a/src/Cluckwork.Domain/Catalog/EggUnitConversion.cs b/src/Cluckwork.Domain/Catalog/EggUnitConversion.cs index 5eaeb3989..c43a5ec77 100644 --- a/src/Cluckwork.Domain/Catalog/EggUnitConversion.cs +++ b/src/Cluckwork.Domain/Catalog/EggUnitConversion.cs @@ -7,11 +7,13 @@ namespace Cluckwork.Domain.Catalog; // depending on the market. Each account carries one row per unit code; part 2 // resolves eggs-per-unit here at line creation and snapshots it on the sold // line, so a later redefinition never reinterprets existing orders. -public sealed class EggUnitConversion : AggregateRoot +public sealed class EggUnitConversion : AggregateRoot, IMutableRecord { public EggUnit UnitCode { get; private set; } public int EggsPerUnit { get; private set; } public bool Active { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private EggUnitConversion() { } diff --git a/src/Cluckwork.Domain/Catalog/Product.cs b/src/Cluckwork.Domain/Catalog/Product.cs index 53ba9722c..244bba90d 100644 --- a/src/Cluckwork.Domain/Catalog/Product.cs +++ b/src/Cluckwork.Domain/Catalog/Product.cs @@ -6,7 +6,7 @@ namespace Cluckwork.Domain.Catalog; // products are creatable (they map to an egg grade via // ProductEggGradeMapping); the enum already carries the future types so the // schema doesn't churn when live birds / meat / services arrive. -public sealed class Product : AggregateRoot +public sealed class Product : AggregateRoot, IMutableRecord { public const int MaxNameLength = 100; public const int MaxNotesLength = 500; @@ -22,6 +22,8 @@ public sealed class Product : AggregateRoot public int CurrencyMinorUnit { get; private set; } public string? Notes { get; private set; } public bool Active { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Product() { } diff --git a/src/Cluckwork.Domain/Catalog/ProductEggGradeMapping.cs b/src/Cluckwork.Domain/Catalog/ProductEggGradeMapping.cs index 7df9834a0..9bddbe62a 100644 --- a/src/Cluckwork.Domain/Catalog/ProductEggGradeMapping.cs +++ b/src/Cluckwork.Domain/Catalog/ProductEggGradeMapping.cs @@ -6,10 +6,12 @@ namespace Cluckwork.Domain.Catalog; // eggs from lots of the mapped grade. Phase 1 enforces exactly ONE mapping per // product (unique index on ProductId); multi-grade products (mixed cartons) // arrive with a later slice, which is why this is a table and not a column. -public sealed class ProductEggGradeMapping : Entity +public sealed class ProductEggGradeMapping : Entity, IMutableRecord { public Guid ProductId { get; private set; } public Guid EggGradeId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } private ProductEggGradeMapping() { } diff --git a/src/Cluckwork.Domain/Common/ICreatedRecord.cs b/src/Cluckwork.Domain/Common/ICreatedRecord.cs new file mode 100644 index 000000000..703fc308b --- /dev/null +++ b/src/Cluckwork.Domain/Common/ICreatedRecord.cs @@ -0,0 +1,6 @@ +namespace Cluckwork.Domain.Common; + +public interface ICreatedRecord +{ + DateTimeOffset CreatedAtUtc { get; } +} diff --git a/src/Cluckwork.Domain/Common/IMutableRecord.cs b/src/Cluckwork.Domain/Common/IMutableRecord.cs new file mode 100644 index 000000000..a02d833a7 --- /dev/null +++ b/src/Cluckwork.Domain/Common/IMutableRecord.cs @@ -0,0 +1,6 @@ +namespace Cluckwork.Domain.Common; + +public interface IMutableRecord : ICreatedRecord +{ + DateTimeOffset UpdatedAtUtc { get; } +} diff --git a/src/Cluckwork.Domain/Eggs/DailyEntry.cs b/src/Cluckwork.Domain/Eggs/DailyEntry.cs index ac513e44e..421ba48e1 100644 --- a/src/Cluckwork.Domain/Eggs/DailyEntry.cs +++ b/src/Cluckwork.Domain/Eggs/DailyEntry.cs @@ -2,7 +2,7 @@ namespace Cluckwork.Domain.Eggs; using System.Text.Json; -public sealed class DailyEntry : AggregateRoot +public sealed class DailyEntry : AggregateRoot, IMutableRecord { public const int MaxReasonLength = 500; @@ -46,6 +46,9 @@ public sealed class DailyEntry : AggregateRoot public string? VoidReason { get; private set; } public DateTimeOffset? LockedAtUtc { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + // Optimistic concurrency token — functional spec §10.9.1 public int Version { get; private set; } @@ -314,11 +317,13 @@ public enum DailyEntryStatus { Draft, Submitted, Locked, ManagerAdjusted, Voided // saleable grades before this reaches the aggregate. public sealed record GradeQuantity(Guid EggGradeId, int Quantity); -public sealed class DailyEntryGrade : Entity +public sealed class DailyEntryGrade : Entity, IMutableRecord { public Guid DailyEntryId { get; private set; } public Guid EggGradeId { get; private set; } public int Quantity { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } private DailyEntryGrade() { } diff --git a/src/Cluckwork.Domain/Eggs/EggGrade.cs b/src/Cluckwork.Domain/Eggs/EggGrade.cs index 6fdc191f2..d3508551b 100644 --- a/src/Cluckwork.Domain/Eggs/EggGrade.cs +++ b/src/Cluckwork.Domain/Eggs/EggGrade.cs @@ -5,7 +5,7 @@ namespace Cluckwork.Domain.Eggs; // axis defines a bucket is the farm's choice. Daily-entry grade lines reference // these rows and are restricted to saleable grades — non-saleable buckets are // captured by the daily entry's cracked/dirty/discarded counts instead. -public sealed class EggGrade : AggregateRoot +public sealed class EggGrade : AggregateRoot, IMutableRecord { public const int MaxNameLength = 50; @@ -24,6 +24,8 @@ public sealed class EggGrade : AggregateRoot // enforced by a partial unique index (see InitialCreate). public DailyEntryKind DailyEntryKind { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private EggGrade() { } diff --git a/src/Cluckwork.Domain/Eggs/EggInventoryMovement.cs b/src/Cluckwork.Domain/Eggs/EggInventoryMovement.cs index 967cca1b7..8e0ed15df 100644 --- a/src/Cluckwork.Domain/Eggs/EggInventoryMovement.cs +++ b/src/Cluckwork.Domain/Eggs/EggInventoryMovement.cs @@ -6,7 +6,7 @@ namespace Cluckwork.Domain.Eggs; // movements (tech-spec rule: cached balances only if rebuildable from // ledgers). Append-only like the audit trail: no Version, no update, no // delete. -public sealed class EggInventoryMovement : AggregateRoot +public sealed class EggInventoryMovement : AggregateRoot, ICreatedRecord { public const int MaxReferenceTypeLength = 50; public const int MaxReasonLength = 500; @@ -26,7 +26,7 @@ private EggInventoryMovement() { } public static EggInventoryMovement Create( Guid id, Guid accountId, Guid eggLotId, EggMovementType movementType, int quantityDelta, string referenceType, Guid referenceId, - DateTimeOffset createdAtUtc, string? reason = null) + string? reason = null) { if (quantityDelta == 0) throw new ArgumentException("A movement must change the quantity.", nameof(quantityDelta)); @@ -41,7 +41,6 @@ public static EggInventoryMovement Create( QuantityDelta = quantityDelta, ReferenceType = referenceType, ReferenceId = referenceId, - CreatedAtUtc = createdAtUtc, Reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim().Length > MaxReasonLength diff --git a/src/Cluckwork.Domain/Eggs/EggLot.cs b/src/Cluckwork.Domain/Eggs/EggLot.cs index 77a12a056..48a02b004 100644 --- a/src/Cluckwork.Domain/Eggs/EggLot.cs +++ b/src/Cluckwork.Domain/Eggs/EggLot.cs @@ -1,6 +1,6 @@ namespace Cluckwork.Domain.Eggs; -public sealed class EggLot : AggregateRoot +public sealed class EggLot : AggregateRoot, IMutableRecord { public Guid FlockId { get; private set; } public DateOnly ProductionDate { get; private set; } @@ -20,6 +20,8 @@ public sealed class EggLot : AggregateRoot // Row-version token for optimistic concurrency on reads; // sales-allocation path uses pessimistic FOR UPDATE (tech spec §3.3). + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private EggLot() { } diff --git a/src/Cluckwork.Domain/Expenses/Expense.cs b/src/Cluckwork.Domain/Expenses/Expense.cs index 1b002d635..410a0c1ba 100644 --- a/src/Cluckwork.Domain/Expenses/Expense.cs +++ b/src/Cluckwork.Domain/Expenses/Expense.cs @@ -6,7 +6,7 @@ namespace Cluckwork.Domain.Expenses; // the farm's currency setting later changes. Amounts are minor units (cents). // Direct flock allocation only in this slice; shared-allocation methods // (bird-count / revenue share) come with profitability reporting. -public sealed class Expense : AggregateRoot +public sealed class Expense : AggregateRoot, IMutableRecord { public const int MaxDescriptionLength = 200; public const int MaxNoteLength = 500; @@ -20,6 +20,8 @@ public sealed class Expense : AggregateRoot public int CurrencyMinorUnit { get; private set; } public Guid? FlockId { get; private set; } public string? Note { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Expense() { } diff --git a/src/Cluckwork.Domain/Expenses/ExpenseCategory.cs b/src/Cluckwork.Domain/Expenses/ExpenseCategory.cs index ac68f72a7..ef364863b 100644 --- a/src/Cluckwork.Domain/Expenses/ExpenseCategory.cs +++ b/src/Cluckwork.Domain/Expenses/ExpenseCategory.cs @@ -4,13 +4,15 @@ namespace Cluckwork.Domain.Expenses; // "Repairs"). Expenses reference these rows; deactivating a category hides it // from new expenses while recorded ones keep rendering it (the grandfathering // pattern grades established). -public sealed class ExpenseCategory : AggregateRoot +public sealed class ExpenseCategory : AggregateRoot, IMutableRecord { public const int MaxNameLength = 100; public Guid FarmId { get; private set; } public string Name { get; private set; } = string.Empty; public bool Active { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private ExpenseCategory() { } diff --git a/src/Cluckwork.Domain/Flocks/BirdMovement.cs b/src/Cluckwork.Domain/Flocks/BirdMovement.cs index 2e28d8920..5921deaf6 100644 --- a/src/Cluckwork.Domain/Flocks/BirdMovement.cs +++ b/src/Cluckwork.Domain/Flocks/BirdMovement.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Domain.Flocks; // // 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 sealed class BirdMovement : AggregateRoot, ICreatedRecord { public const int MaxNoteLength = 500; @@ -20,6 +20,7 @@ public sealed class BirdMovement : AggregateRoot // 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; } + public DateTimeOffset CreatedAtUtc { get; private set; } private BirdMovement() { } diff --git a/src/Cluckwork.Domain/Flocks/Flock.cs b/src/Cluckwork.Domain/Flocks/Flock.cs index a7383838c..0c9ab0898 100644 --- a/src/Cluckwork.Domain/Flocks/Flock.cs +++ b/src/Cluckwork.Domain/Flocks/Flock.cs @@ -1,6 +1,6 @@ namespace Cluckwork.Domain.Flocks; -public sealed class Flock : AggregateRoot +public sealed class Flock : AggregateRoot, IMutableRecord { // Match the column widths (Name 200 / Breed 100) so validators and schema // agree on one limit. @@ -20,6 +20,8 @@ public sealed class Flock : AggregateRoot // calendar day silently moves that cutoff. public DateOnly? DepletedOn { get; private set; } public DateOnly? ArchivedOn { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Flock() { } diff --git a/src/Cluckwork.Domain/Inventory/FeedUsage.cs b/src/Cluckwork.Domain/Inventory/FeedUsage.cs index f9ce4a3ad..b9bb61a90 100644 --- a/src/Cluckwork.Domain/Inventory/FeedUsage.cs +++ b/src/Cluckwork.Domain/Inventory/FeedUsage.cs @@ -4,7 +4,7 @@ namespace Cluckwork.Domain.Inventory; // Created only — the stock effect lives in the lots/movement ledger, and a // mis-entered usage is corrected there via a compensating Adjustment (an edit // here would silently detach the record from the movements it generated). -public sealed class FeedUsage : AggregateRoot +public sealed class FeedUsage : AggregateRoot, ICreatedRecord { public const int MaxNoteLength = 500; @@ -28,7 +28,7 @@ public sealed class FeedUsage : AggregateRoot public string? Note { get; private set; } // Append timestamp — same-day records order by this, like movement rows. - public DateTime CreatedAtUtc { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } public int Version { get; private set; } @@ -37,7 +37,7 @@ private FeedUsage() { } public static FeedUsage Create( Guid id, Guid accountId, Guid flockId, Guid inventoryItemId, DateOnly date, decimal quantity, string unit, Money estimatedCost, - DateTime createdAtUtc, string? note = null, Guid? dailyEntryId = null) + string? note = null, Guid? dailyEntryId = null) { if (flockId == Guid.Empty) throw new ArgumentException("Flock id is required.", nameof(flockId)); @@ -61,7 +61,6 @@ public static FeedUsage Create( Quantity = quantity, Unit = unit, EstimatedCost = estimatedCost, - CreatedAtUtc = createdAtUtc, Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(), DailyEntryId = dailyEntryId }; diff --git a/src/Cluckwork.Domain/Inventory/InventoryItem.cs b/src/Cluckwork.Domain/Inventory/InventoryItem.cs index d5c9e294e..431f8c117 100644 --- a/src/Cluckwork.Domain/Inventory/InventoryItem.cs +++ b/src/Cluckwork.Domain/Inventory/InventoryItem.cs @@ -4,7 +4,7 @@ namespace Cluckwork.Domain.Inventory; // (#66), but the category axis covers the later medication/vaccine/etc. // slices so the schema doesn't churn. Quantities live on lots; an item is the // definition (what it is, how it's measured, what it usually costs). -public sealed class InventoryItem : AggregateRoot +public sealed class InventoryItem : AggregateRoot, IMutableRecord { public const int MaxNameLength = 200; public const int MaxUnitLength = 20; @@ -22,6 +22,8 @@ public sealed class InventoryItem : AggregateRoot public Money? DefaultUnitCost { get; private set; } public bool Active { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private InventoryItem() { } diff --git a/src/Cluckwork.Domain/Inventory/InventoryLot.cs b/src/Cluckwork.Domain/Inventory/InventoryLot.cs index 7c15fc0b9..b7b66b7e4 100644 --- a/src/Cluckwork.Domain/Inventory/InventoryLot.cs +++ b/src/Cluckwork.Domain/Inventory/InventoryLot.cs @@ -4,7 +4,7 @@ namespace Cluckwork.Domain.Inventory; // own cost and optional supplier lot number / expiry. Stock on hand for an // item = Σ QuantityAvailable across its lots — the lots are the source of // truth; InventoryMovement rows are the audit ledger derived from them. -public sealed class InventoryLot : AggregateRoot +public sealed class InventoryLot : AggregateRoot, IMutableRecord { public const int MaxLotNumberLength = 100; @@ -21,6 +21,8 @@ public sealed class InventoryLot : AggregateRoot // Row-version token; the usage path consumes under FOR UPDATE like egg-lot // allocation (canonical (ReceivedDate, Id) lock order). + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private InventoryLot() { } diff --git a/src/Cluckwork.Domain/Inventory/InventoryMovement.cs b/src/Cluckwork.Domain/Inventory/InventoryMovement.cs index a9db4b04d..e6b9d30ec 100644 --- a/src/Cluckwork.Domain/Inventory/InventoryMovement.cs +++ b/src/Cluckwork.Domain/Inventory/InventoryMovement.cs @@ -5,7 +5,7 @@ namespace Cluckwork.Domain.Inventory; // seam rejects Update/Remove). Mistakes are corrected by a compensating // Adjustment row. Lot QuantityAvailable is the balance; these rows are the // explanation of how it got there. -public sealed class InventoryMovement : AggregateRoot +public sealed class InventoryMovement : AggregateRoot, ICreatedRecord { public const int MaxNoteLength = 500; @@ -27,7 +27,7 @@ public sealed class InventoryMovement : AggregateRoot // When the row was appended (as opposed to Date, the backdatable operational // day) — same-day rows order by this, keeping ledger chronology honest. // CreatedBy joins with the audit-log slice; single login today. - public DateTime CreatedAtUtc { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } // What generated this row (spec §12.3 reference_type/reference_id): // "FeedUsage" + the usage id for usage rows, so several same-day feedings @@ -41,7 +41,7 @@ private InventoryMovement() { } public static InventoryMovement Create( Guid accountId, Guid inventoryItemId, Guid? inventoryLotId, DateOnly date, InventoryMovementType type, decimal quantityDelta, - string unit, DateTime createdAtUtc, Guid? flockId = null, string? note = null, + string unit, Guid? flockId = null, string? note = null, string? referenceType = null, Guid? referenceId = null) { if ((referenceType is null) != (referenceId is null)) @@ -69,7 +69,6 @@ public static InventoryMovement Create( Type = type, QuantityDelta = quantityDelta, Unit = unit, - CreatedAtUtc = createdAtUtc, ReferenceType = referenceType, ReferenceId = referenceId, FlockId = flockId, diff --git a/src/Cluckwork.Domain/Inventory/WaterUsage.cs b/src/Cluckwork.Domain/Inventory/WaterUsage.cs index bce1e5695..37c1a4ad3 100644 --- a/src/Cluckwork.Domain/Inventory/WaterUsage.cs +++ b/src/Cluckwork.Domain/Inventory/WaterUsage.cs @@ -4,7 +4,7 @@ namespace Cluckwork.Domain.Inventory; // lot/ledger behind water — recording it affects nothing downstream — so the // record itself is EDITABLE (Update + Version token) rather than corrected via // compensating rows. Corrections become admin-gated with #73. -public sealed class WaterUsage : AggregateRoot +public sealed class WaterUsage : AggregateRoot, IMutableRecord { public const int MaxNoteLength = 500; public static readonly string[] AllowedUnits = ["L", "gal"]; @@ -26,7 +26,8 @@ public sealed class WaterUsage : AggregateRoot // touches it. See FeedUsage for the full contract. public Guid? DailyEntryId { get; private set; } - public DateTime CreatedAtUtc { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private WaterUsage() { } @@ -34,7 +35,7 @@ private WaterUsage() { } public static WaterUsage Create( Guid id, Guid accountId, Guid flockId, DateOnly date, decimal quantity, string unit, WaterSource source, - decimal? meterStart, decimal? meterEnd, DateTime createdAtUtc, + decimal? meterStart, decimal? meterEnd, string? note = null, Guid? dailyEntryId = null) { var guard = Validate(flockId, quantity, unit, meterStart, meterEnd, note); @@ -47,7 +48,6 @@ public static WaterUsage Create( Quantity = quantity, Unit = unit, Source = source, MeterStart = meterStart, MeterEnd = meterEnd, - CreatedAtUtc = createdAtUtc, Note = string.IsNullOrWhiteSpace(note) ? null : note.Trim(), DailyEntryId = dailyEntryId }; diff --git a/src/Cluckwork.Domain/Sales/Customer.cs b/src/Cluckwork.Domain/Sales/Customer.cs index 4fdb36f0a..be856f524 100644 --- a/src/Cluckwork.Domain/Sales/Customer.cs +++ b/src/Cluckwork.Domain/Sales/Customer.cs @@ -5,7 +5,7 @@ namespace Cluckwork.Domain.Sales; // MVP customer (issue #10): reference-app shape — name + phone required, // email/address/note optional. Balances, credit terms, and payments are // Phase 1.1. -public sealed class Customer : AggregateRoot +public sealed class Customer : AggregateRoot, IMutableRecord { public const int MaxNameLength = 200; public const int MaxPhoneLength = 50; @@ -18,6 +18,8 @@ public sealed class Customer : AggregateRoot public string? Email { get; private set; } public string? Address { get; private set; } public string? Note { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Customer() { } diff --git a/src/Cluckwork.Domain/Sales/Payment.cs b/src/Cluckwork.Domain/Sales/Payment.cs index 13c6c6e99..2c66b0d56 100644 --- a/src/Cluckwork.Domain/Sales/Payment.cs +++ b/src/Cluckwork.Domain/Sales/Payment.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Domain.Sales; // rule transactionally (Σ non-voided payments ≤ order total). // Corrections follow the void pattern (#89): a mistaken payment is voided // with a reason, never deleted — the row stays for the ledger. -public sealed class Payment : AggregateRoot +public sealed class Payment : AggregateRoot, IMutableRecord { public const int MaxReferenceLength = 50; public const int MaxNoteLength = 500; @@ -23,6 +23,8 @@ public sealed class Payment : AggregateRoot public string? Note { get; private set; } public bool Voided { get; private set; } public string? VoidReason { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } private Payment() { } diff --git a/src/Cluckwork.Domain/Sales/SalesOrder.cs b/src/Cluckwork.Domain/Sales/SalesOrder.cs index 17f0e4d57..5380c7355 100644 --- a/src/Cluckwork.Domain/Sales/SalesOrder.cs +++ b/src/Cluckwork.Domain/Sales/SalesOrder.cs @@ -1,6 +1,6 @@ namespace Cluckwork.Domain.Sales; -public sealed class SalesOrder : AggregateRoot +public sealed class SalesOrder : AggregateRoot, IMutableRecord { public const int MaxVoidReasonLength = 500; public const int MaxDiscountReasonNoteLength = 500; @@ -26,6 +26,8 @@ public sealed class SalesOrder : AggregateRoot /// meaningless without it. Never non-null while the code is null. /// public string? DiscountReasonNote { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } public int Version { get; private set; } public IReadOnlyList Items => _items.AsReadOnly(); @@ -420,7 +422,7 @@ public enum ListPriceBasis PreDating, } -public sealed class SalesOrderItem : Entity +public sealed class SalesOrderItem : Entity, IMutableRecord { public Guid SalesOrderId { get; private set; } public Guid ProductId { get; private set; } @@ -463,6 +465,8 @@ public sealed class SalesOrderItem : Entity /// public ListPriceBasis ListPriceBasis { get; private set; } public Money LineTotal => UnitPrice.Multiply(Quantity); + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } private SalesOrderItem() { } diff --git a/src/Cluckwork.Domain/Sales/SalesOrderAllocation.cs b/src/Cluckwork.Domain/Sales/SalesOrderAllocation.cs index 043868cd2..4d304fe50 100644 --- a/src/Cluckwork.Domain/Sales/SalesOrderAllocation.cs +++ b/src/Cluckwork.Domain/Sales/SalesOrderAllocation.cs @@ -7,7 +7,7 @@ namespace Cluckwork.Domain.Sales; // (stock returned to the source lots) instead of deleting them. A Confirmed // order therefore always has pending rows (orders confirmed before this table // existed are the one exception; those cannot be voided). -public sealed class SalesOrderAllocation : Entity +public sealed class SalesOrderAllocation : Entity, IMutableRecord { public Guid SalesOrderId { get; private set; } public Guid SalesOrderItemId { get; private set; } @@ -17,6 +17,8 @@ public sealed class SalesOrderAllocation : Entity // Set when a void returned this quantity to the source lot. Null = the // allocation is live (the order holds this stock). public DateTime? ReleasedOnUtc { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } private SalesOrderAllocation() { } diff --git a/src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs b/src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs index 14d3be3b5..dc8c7a59f 100644 --- a/src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs +++ b/src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs @@ -1,12 +1,15 @@ namespace Cluckwork.Infrastructure.Identity; using Cluckwork.Domain.Catalog; +using Cluckwork.Domain.Common; using Microsoft.AspNetCore.Identity; -public sealed class ApplicationUser : IdentityUser +public sealed class ApplicationUser : IdentityUser, IMutableRecord { public Guid AccountId { get; set; } public string? DisplayName { get; set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } // #45 — the user's UI-language preference, a nullable BCP-47 primary subtag // (lowercased). NOT a locale: regional/number/date formatting stays a diff --git a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs index 5e89e1049..4414e3b54 100644 --- a/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs +++ b/src/Cluckwork.Infrastructure/Identity/IdentityProvider.cs @@ -712,25 +712,7 @@ public Task ChangeUserEmailAsync( } var entry = db.Entry(user); - var priorModified = entry.Properties.ToDictionary( - property => property.Metadata.Name, - property => property.IsModified, - StringComparer.Ordinal); - var priorValues = new UserEmailMutationSnapshot( - user.Email, - user.NormalizedEmail, - user.UserName, - user.NormalizedUserName, - user.SecurityStamp, - user.ConcurrencyStamp, - user.CredentialEpoch, - entry.Property(candidate => candidate.Email).OriginalValue, - entry.Property(candidate => candidate.NormalizedEmail).OriginalValue, - entry.Property(candidate => candidate.UserName).OriginalValue, - entry.Property(candidate => candidate.NormalizedUserName).OriginalValue, - entry.Property(candidate => candidate.SecurityStamp).OriginalValue, - entry.Property(candidate => candidate.ConcurrencyStamp).OriginalValue, - entry.Property(candidate => candidate.CredentialEpoch).OriginalValue); + var targetSnapshot = TrackedEntrySnapshot.Capture(entry); var priorAudits = db.ChangeTracker.Entries() .Select(auditEntry => auditEntry.Entity) .ToHashSet(ReferenceEqualityComparer.Instance); @@ -741,37 +723,16 @@ public Task ChangeUserEmailAsync( // never sent to PostgreSQL, so restoration never has to replace a // real generated key with its old temporary value. var priorDirtyEntries = db.ChangeTracker.Entries() - .Where(candidate => candidate.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Where(candidate => !ReferenceEquals(candidate.Entity, user) + && candidate.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) .Select(TrackedEntrySnapshot.Capture) .ToArray(); void RestoreTrackerAfterFailure() { - user.Email = priorValues.Email; - user.NormalizedEmail = priorValues.NormalizedEmail; - user.UserName = priorValues.UserName; - user.NormalizedUserName = priorValues.NormalizedUserName; - user.SecurityStamp = priorValues.SecurityStamp; - user.ConcurrencyStamp = priorValues.ConcurrencyStamp; - user.CredentialEpoch = priorValues.CredentialEpoch; - - entry.Property(candidate => candidate.Email).OriginalValue = priorValues.OriginalEmail; - entry.Property(candidate => candidate.NormalizedEmail).OriginalValue = priorValues.OriginalNormalizedEmail; - entry.Property(candidate => candidate.UserName).OriginalValue = priorValues.OriginalUserName; - entry.Property(candidate => candidate.NormalizedUserName).OriginalValue = priorValues.OriginalNormalizedUserName; - entry.Property(candidate => candidate.SecurityStamp).OriginalValue = priorValues.OriginalSecurityStamp; - entry.Property(candidate => candidate.ConcurrencyStamp).OriginalValue = priorValues.OriginalConcurrencyStamp; - entry.Property(candidate => candidate.CredentialEpoch).OriginalValue = priorValues.OriginalCredentialEpoch; - foreach (var property in entry.Properties) - property.IsModified = priorModified[property.Metadata.Name]; - + targetSnapshot.RestoreTargetAfterFailure(); foreach (var dirtyEntry in priorDirtyEntries) - { - if (ReferenceEquals(dirtyEntry.Entry.Entity, user)) - dirtyEntry.RestoreTargetAfterFailure(); - else - dirtyEntry.RestoreExact(); - } + dirtyEntry.RestoreExact(); foreach (var auditEntry in db.ChangeTracker.Entries() .Where(candidate => candidate.State == EntityState.Added @@ -785,13 +746,9 @@ void RestoreTrackerAfterFailure() void RestoreCallerEntriesAfterSuccess() { + targetSnapshot.RestoreTargetAfterSuccessfulSave(); foreach (var dirtyEntry in priorDirtyEntries) - { - if (ReferenceEquals(dirtyEntry.Entry.Entity, user)) - dirtyEntry.RestoreTargetAfterSuccessfulSave(); - else - dirtyEntry.RestoreExact(); - } + dirtyEntry.RestoreExact(); } try @@ -800,8 +757,9 @@ void RestoreCallerEntriesAfterSuccess() // same restore boundary as both saves. If a later snapshot // throws while being suppressed, the earlier ones are still // restored exactly by the catch below. + targetSnapshot.Suspend(isTarget: true); foreach (var dirtyEntry in priorDirtyEntries) - dirtyEntry.Suspend(ReferenceEquals(dirtyEntry.Entry.Entity, user)); + dirtyEntry.Suspend(isTarget: false); var oldEmail = user.Email; user.Email = email; @@ -873,27 +831,6 @@ exception.InnerException is PostgresException ConstraintName: "EmailIndex" or "UserNameIndex" }; - private sealed record UserEmailMutationSnapshot( - // IdentityUser declares these as nullable even though this app's EF - // model requires all four login columns. Keep the compiler-truthful - // nullable types here; the strongly typed EF property access below - // avoids the former object? originals without asserting a narrower - // C# invariant than ApplicationUser actually declares. - string? Email, - string? NormalizedEmail, - string? UserName, - string? NormalizedUserName, - string? SecurityStamp, - string? ConcurrencyStamp, - int CredentialEpoch, - string? OriginalEmail, - string? OriginalNormalizedEmail, - string? OriginalUserName, - string? OriginalNormalizedUserName, - string? OriginalSecurityStamp, - string? OriginalConcurrencyStamp, - int OriginalCredentialEpoch); - private sealed record TrackedEntrySnapshot( EntityEntry Entry, EntityState State, diff --git a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs index 9a4b04b43..d173edb83 100644 --- a/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs +++ b/src/Cluckwork.Infrastructure/Persistence/AppDbContext.cs @@ -60,6 +60,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ConfigureIdempotency(); builder.ConfigureSimulationSeedState(); builder.ConfigureDurableJobs(); + BusinessRecordModel.Apply(builder); // Global query filters enforce tenant isolation on every read (tech spec §4.2). // A missing WHERE can never leak cross-tenant data. diff --git a/src/Cluckwork.Infrastructure/Persistence/BusinessRecordModel.cs b/src/Cluckwork.Infrastructure/Persistence/BusinessRecordModel.cs new file mode 100644 index 000000000..cded3a4cb --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/BusinessRecordModel.cs @@ -0,0 +1,153 @@ +namespace Cluckwork.Infrastructure.Persistence; + +using Cluckwork.Domain.Auditing; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Eggs; +using Cluckwork.Domain.Expenses; +using Cluckwork.Domain.Flocks; +using Cluckwork.Domain.Inventory; +using Cluckwork.Domain.Sales; +using Cluckwork.Infrastructure.Identity; +using Cluckwork.Infrastructure.Jobs; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +internal static class BusinessRecordModel +{ + // Sequence is persistence-only, so this is the deliberate policy list; + // unlike timestamp classification, no domain interface should expose it. + private static readonly Type[] ChronologicalListTypes = + [ + typeof(SalesOrder), + typeof(Expense), + typeof(DailyEntry), + typeof(EggLot), + typeof(BirdMovement), + typeof(Payment), + typeof(InventoryLot), + typeof(FeedUsage), + typeof(WaterUsage), + typeof(InventoryMovement), + typeof(EggInventoryMovement) + ]; + + private static readonly Type[] MappedExclusions = + [ + typeof(AuditEvent), + typeof(ApplicationRole), + typeof(IdentityRoleClaim), + typeof(IdentityUserClaim), + typeof(IdentityUserLogin), + typeof(IdentityUserRole), + typeof(IdentityUserToken), + typeof(IdentityPasskeyData), + typeof(RefreshToken), + typeof(IdempotencyRecord), + typeof(SimulationSeedState), + typeof(DurableJob) + ]; + + public static void Apply(ModelBuilder builder) + { + var mappedTypes = builder.Model.GetEntityTypes() + .Where(entityType => !entityType.IsOwned()) + .Select(entityType => entityType.ClrType) + .ToHashSet(); + var timestampedRecords = mappedTypes + .Where(typeof(ICreatedRecord).IsAssignableFrom) + .ToHashSet(); + var chronologicalLists = ChronologicalListTypes.ToHashSet(); + var exclusions = MappedExclusions.ToHashSet(); + + ValidateCensus(mappedTypes, timestampedRecords, chronologicalLists, exclusions); + + foreach (var recordType in timestampedRecords) + { + var entityType = builder.Model.FindEntityType(recordType)!; + ConfigureTimestamps(builder.Entity(recordType), entityType, recordType); + } + + foreach (var listType in chronologicalLists) + { + var entity = builder.Entity(listType); + var sequence = entity.Property("Sequence") + .ValueGeneratedOnAdd() + .UseIdentityAlwaysColumn(); + sequence.Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore); + sequence.Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw); + entity.HasIndex("Sequence").IsUnique(); + } + } + + private static void ValidateCensus( + HashSet mappedTypes, + HashSet timestampedRecords, + HashSet chronologicalLists, + HashSet exclusions) + { + if (ChronologicalListTypes.Length != chronologicalLists.Count) + throw new InvalidOperationException("The chronological-list census contains a duplicate type."); + if (!chronologicalLists.IsSubsetOf(timestampedRecords)) + throw new InvalidOperationException("Every chronological list must be a timestamped business record."); + if (timestampedRecords.Overlaps(exclusions)) + throw new InvalidOperationException("A mapped entity cannot be both timestamped and excluded."); + + foreach (var excludedType in exclusions) + { + if (!mappedTypes.Contains(excludedType)) + throw new InvalidOperationException($"Mapped exclusion '{excludedType.Name}' is not mapped."); + } + + var unclassifiedTypes = mappedTypes + .Except(timestampedRecords) + .Except(exclusions) + .OrderBy(type => type.Name, StringComparer.Ordinal) + .ToArray(); + if (unclassifiedTypes.Length != 0) + throw new InvalidOperationException( + $"Mapped entities have no business-record timestamp policy: {string.Join(", ", unclassifiedTypes.Select(type => type.Name))}."); + } + + private static void ConfigureTimestamps( + EntityTypeBuilder entity, + IReadOnlyEntityType entityType, + Type recordType) + { + var createdAt = entityType.FindProperty(nameof(ICreatedRecord.CreatedAtUtc)); + ValidateTimestampProperty(createdAt, recordType, nameof(ICreatedRecord.CreatedAtUtc)); + ConfigureCreatedTimestamp(entity.Property(nameof(ICreatedRecord.CreatedAtUtc))); + + if (typeof(IMutableRecord).IsAssignableFrom(recordType)) + { + var updatedAt = entityType.FindProperty(nameof(IMutableRecord.UpdatedAtUtc)); + ValidateTimestampProperty(updatedAt, recordType, nameof(IMutableRecord.UpdatedAtUtc)); + ConfigureUpdatedTimestamp(entity.Property(nameof(IMutableRecord.UpdatedAtUtc))); + return; + } + + if (entityType.FindProperty(nameof(IMutableRecord.UpdatedAtUtc)) is not null) + throw new InvalidOperationException($"Created-only business record '{recordType.Name}' maps {nameof(IMutableRecord.UpdatedAtUtc)}."); + } + + private static void ValidateTimestampProperty(IReadOnlyProperty? property, Type recordType, string propertyName) + { + if (property is null || property.ClrType != typeof(DateTimeOffset) || property.IsNullable) + throw new InvalidOperationException($"Business record '{recordType.Name}' must map a non-null {nameof(DateTimeOffset)} {propertyName}."); + } + + private static void ConfigureCreatedTimestamp(PropertyBuilder property) + { + property.ValueGeneratedOnAdd(); + property.Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore); + property.Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Throw); + } + + private static void ConfigureUpdatedTimestamp(PropertyBuilder property) + { + property.ValueGeneratedOnAddOrUpdate(); + property.Metadata.SetBeforeSaveBehavior(PropertySaveBehavior.Ignore); + property.Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore); + } +} diff --git a/src/Cluckwork.Infrastructure/Persistence/BusinessRecordOrdering.cs b/src/Cluckwork.Infrastructure/Persistence/BusinessRecordOrdering.cs new file mode 100644 index 000000000..43c48d0ae --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/BusinessRecordOrdering.cs @@ -0,0 +1,36 @@ +namespace Cluckwork.Infrastructure.Persistence; + +using System.Linq.Expressions; +using Cluckwork.Domain.Common; +using Microsoft.EntityFrameworkCore; + +internal static class BusinessRecordOrdering +{ + private const string SequenceProperty = "Sequence"; + + public static IOrderedQueryable OrderByBusinessChronology( + this IQueryable query, + Expression> businessDate) + where T : class, ICreatedRecord => + query.OrderBy(businessDate) + .ThenBy(record => record.CreatedAtUtc) + .ThenBy(record => EF.Property(record, SequenceProperty)); + + public static IOrderedQueryable OrderByBusinessChronologyDescending( + this IQueryable query, + Expression> businessDate) + where T : class, ICreatedRecord => + query.OrderByDescending(businessDate) + .ThenByDescending(record => record.CreatedAtUtc) + .ThenByDescending(record => EF.Property(record, SequenceProperty)); + + public static IOrderedQueryable OrderByCreationChronology(this IQueryable query) + where T : class, ICreatedRecord => + query.OrderBy(record => record.CreatedAtUtc) + .ThenBy(record => EF.Property(record, SequenceProperty)); + + public static IOrderedQueryable OrderByCreationChronologyDescending(this IQueryable query) + where T : class, ICreatedRecord => + query.OrderByDescending(record => record.CreatedAtUtc) + .ThenByDescending(record => EF.Property(record, SequenceProperty)); +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/20260913212515_AddBusinessRecordChronology.Designer.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260913212515_AddBusinessRecordChronology.Designer.cs new file mode 100644 index 000000000..fdd52f513 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260913212515_AddBusinessRecordChronology.Designer.cs @@ -0,0 +1,2475 @@ +// +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("20260913212515_AddBusinessRecordChronology")] + partial class AddBusinessRecordChronology + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Brand") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DateFormatOverride") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DefaultCurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DefaultCurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("DefaultCurrencySymbol") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("DefaultStepperUnit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("FirstDayOfWeek") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Locale") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MaxDiscountBasisPoints") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeFormatOverride") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UnitSystem") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("WorkerSaleAllocationPolicy") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Accounts", t => + { + t.HasCheckConstraint("CK_Accounts_MaxDiscountBasisPoints", "\"MaxDiscountBasisPoints\" IS NULL OR \"MaxDiscountBasisPoints\" BETWEEN 0 AND 10000"); + }); + }); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.FarmLogo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("BannerByteLength") + .HasColumnType("integer"); + + b.Property("BannerContent") + .HasColumnType("bytea"); + + b.Property("BannerContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BannerContentType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BannerHeight") + .HasColumnType("integer"); + + b.Property("BannerUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BannerWidth") + .HasColumnType("integer"); + + b.Property("ByteLength") + .HasColumnType("integer"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId") + .IsUnique(); + + b.ToTable("FarmLogos", t => + { + t.HasCheckConstraint("ck_farm_logos_banner_content_length", "\"BannerContent\" IS NULL OR (octet_length(\"BannerContent\") > 0 AND octet_length(\"BannerContent\") <= 15728640)"); + + t.HasCheckConstraint("ck_farm_logos_content_length", "\"Content\" IS NULL OR (octet_length(\"Content\") > 0 AND octet_length(\"Content\") <= 5242880)"); + }); + }); + + modelBuilder.Entity("Cluckwork.Domain.Accounts.UserRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "UserId"); + + b.HasIndex("UserId", "FlockId") + .IsUnique(); + + b.ToTable("UserRoleAssignments"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Auditing.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("DetailsJson") + .HasColumnType("text"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EntityId"); + + b.HasIndex("AccountId", "OccurredAtUtc"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.EggUnitConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("EggsPerUnit") + .HasColumnType("integer"); + + b.Property("UnitCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "UnitCode") + .IsUnique(); + + b.ToTable("EggUnitConversions"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("DefaultPriceMinorUnits") + .HasColumnType("bigint"); + + b.Property("DefaultUnit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProductType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Catalog.ProductEggGradeMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("ProductId") + .IsUnique(); + + b.ToTable("ProductEggGradeMappings"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("AdjustReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AdjustedFromJson") + .HasColumnType("text"); + + b.Property("CrackedEggs") + .HasColumnType("integer"); + + b.Property("CrackedGradeId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DirtyEggs") + .HasColumnType("integer"); + + b.Property("DirtyGradeId") + .HasColumnType("uuid"); + + b.Property("DiscardedEggs") + .HasColumnType("integer"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("HouseId") + .HasColumnType("uuid"); + + b.Property("LockedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MortalityCount") + .HasColumnType("integer"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TotalEggs") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") + .IsUnique() + .HasDatabaseName("IX_DailyEntries_NaturalKey") + .HasFilter("\"Status\" <> 'Voided'"); + + b.ToTable("DailyEntries"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.DailyEntryGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + 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") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + 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("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "DailyEntryKind") + .IsUnique() + .HasFilter("\"DailyEntryKind\" <> 'Manual'"); + + b.ToTable("EggGrades"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggInventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("EggLotId") + .HasColumnType("uuid"); + + b.Property("MovementType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("QuantityDelta") + .HasColumnType("integer"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.HasKey("Id"); + + b.HasIndex("EggLotId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "EggLotId", "CreatedAtUtc"); + + b.ToTable("EggInventoryMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Eggs.EggLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .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("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId") + .HasDatabaseName("IX_EggLots_DailyEntryId"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") + .HasDatabaseName("IX_EggLots_Allocation"); + + b.ToTable("EggLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpenseCategoryId") + .HasColumnType("uuid"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseCategoryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "Date"); + + b.HasIndex("AccountId", "ExpenseCategoryId"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Expenses.ExpenseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ExpenseCategories"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.BirdMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + 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("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("FlockId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "FlockId", "Date"); + + b.ToTable("BirdMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Flocks.Flock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("ArchivedOn") + .HasColumnType("date"); + + b.Property("Breed") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + 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("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId", "HouseId"); + + b.ToTable("Flocks"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.FeedUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("FlockId", "Date"); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("FeedUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("FarmId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "FarmId"); + + b.ToTable("InventoryItems"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("LotNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("QuantityAvailable") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("QuantityReceived") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReceivedDate") + .HasColumnType("date"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("InventoryItemId", "ReceivedDate"); + + b.ToTable("InventoryLots"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("InventoryItemId") + .HasColumnType("uuid"); + + b.Property("InventoryLotId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("QuantityDelta") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("ReferenceId") + .HasColumnType("uuid"); + + b.Property("ReferenceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("FlockId"); + + b.HasIndex("InventoryLotId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("InventoryItemId", "Date"); + + b.ToTable("InventoryMovements"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("DailyEntryId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("FlockId") + .HasColumnType("uuid"); + + b.Property("MeterEnd") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("MeterStart") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Quantity") + .HasPrecision(18, 3) + .HasColumnType("numeric(18,3)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DailyEntryId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("FlockId", "Date"); + + b.ToTable("WaterUsages"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + 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.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "Name"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("AmountMinorUnits") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("CurrencyMinorUnit") + .HasColumnType("integer"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PaymentDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Voided") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "CustomerId"); + + b.HasIndex("AccountId", "SalesOrderId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("DiscountReasonCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("DiscountReasonNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OrderDate") + .HasColumnType("date"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("Sequence") + .IsUnique(); + + b.HasIndex("AccountId", "ReferenceNumber") + .IsUnique(); + + b.ToTable("SalesOrders"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderAllocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("EggLotId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("ReleasedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("SalesOrderItemId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EggLotId"); + + b.HasIndex("SalesOrderId"); + + b.HasIndex("SalesOrderItemId"); + + b.ToTable("SalesOrderAllocations"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Sales.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("BaseUnitFactor") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("EggGradeId") + .HasColumnType("uuid"); + + b.Property("ListPriceBasis") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ListUnitPriceMinorUnits") + .HasColumnType("bigint"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ProductTypeSnapshot") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("QuantityBase") + .HasColumnType("integer"); + + b.Property("SalesOrderId") + .HasColumnType("uuid"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EggGradeId"); + + b.HasIndex("ProductId"); + + 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") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialEpoch") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("uuid"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PreferredStepperUnit") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("StepUpLogoutEpoch") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "NormalizedEmail") + .IsUnique() + .HasDatabaseName("EmailIndex"); + + b.HasIndex("AccountId", "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") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedEpoch") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByGrace") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + 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") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + 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("LeaseExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaseOwner") + .HasColumnType("uuid"); + + b.Property("RequestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "EndpointHash", "IdempotencyKeyHash") + .IsUnique(); + + b.ToTable("idempotency_records", (string)null); + }); + + modelBuilder.Entity("Cluckwork.Infrastructure.Persistence.SimulationSeedState", b => + { + b.Property("AccountId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Anchor") + .HasColumnType("date"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId"); + + b.ToTable("simulation_seed_state", (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.Property("AccountId") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId", "AccountId"); + + 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.Catalog.ProductEggGradeMapping", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggGrade", null) + .WithMany() + .HasForeignKey("EggGradeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Catalog.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.EggInventoryMovement", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggLot", null) + .WithMany() + .HasForeignKey("EggLotId") + .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.Expenses.Expense", b => + { + b.HasOne("Cluckwork.Domain.Expenses.ExpenseCategory", null) + .WithMany() + .HasForeignKey("ExpenseCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + }); + + 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.Inventory.FeedUsage", 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(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "EstimatedCost", b1 => + { + b1.Property("FeedUsageId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("EstimatedCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("EstimatedCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("EstimatedCostMinorUnits"); + + b1.HasKey("FeedUsageId"); + + b1.ToTable("FeedUsages"); + + b1.WithOwner() + .HasForeignKey("FeedUsageId"); + }); + + b.Navigation("EstimatedCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryItem", b => + { + b.OwnsOne("Cluckwork.Domain.Common.Money", "DefaultUnitCost", b1 => + { + b1.Property("InventoryItemId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("DefaultCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("DefaultCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("DefaultCostMinorUnits"); + + b1.HasKey("InventoryItemId"); + + b1.ToTable("InventoryItems"); + + b1.WithOwner() + .HasForeignKey("InventoryItemId"); + }); + + b.Navigation("DefaultUnitCost"); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryLot", b => + { + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Cluckwork.Domain.Common.Money", "UnitCost", b1 => + { + b1.Property("InventoryLotId") + .HasColumnType("uuid"); + + b1.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("UnitCostCurrencyCode"); + + b1.Property("CurrencyMinorUnit") + .HasColumnType("integer") + .HasColumnName("UnitCostCurrencyMinorUnit"); + + b1.Property("MinorUnits") + .HasColumnType("bigint") + .HasColumnName("UnitCostMinorUnits"); + + b1.HasKey("InventoryLotId"); + + b1.ToTable("InventoryLots"); + + b1.WithOwner() + .HasForeignKey("InventoryLotId"); + }); + + b.Navigation("UnitCost") + .IsRequired(); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.InventoryMovement", b => + { + b.HasOne("Cluckwork.Domain.Flocks.Flock", null) + .WithMany() + .HasForeignKey("FlockId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryItem", null) + .WithMany() + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Inventory.InventoryLot", null) + .WithMany() + .HasForeignKey("InventoryLotId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Cluckwork.Domain.Inventory.WaterUsage", 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.Payment", b => + { + b.HasOne("Cluckwork.Domain.Sales.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .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.SalesOrderAllocation", b => + { + b.HasOne("Cluckwork.Domain.Eggs.EggLot", null) + .WithMany() + .HasForeignKey("EggLotId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrder", null) + .WithMany() + .HasForeignKey("SalesOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Cluckwork.Domain.Sales.SalesOrderItem", null) + .WithMany() + .HasForeignKey("SalesOrderItemId") + .OnDelete(DeleteBehavior.Cascade) + .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.Catalog.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .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("Cluckwork.Infrastructure.Identity.ApplicationUser", b => + { + b.HasOne("Cluckwork.Domain.Accounts.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict) + .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(); + + b.HasOne("Cluckwork.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId", "AccountId") + .HasPrincipalKey("Id", "AccountId") + .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/20260913212515_AddBusinessRecordChronology.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260913212515_AddBusinessRecordChronology.cs new file mode 100644 index 000000000..39b36a075 --- /dev/null +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/20260913212515_AddBusinessRecordChronology.cs @@ -0,0 +1,325 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cluckwork.Infrastructure.Persistence.Migrations; + +public partial class AddBusinessRecordChronology : Migration +{ + private const string UnknownCreatedAtUtc = "1970-01-01 00:00:00+00"; + + private static readonly (string Table, string EntityType, string[] Actions)[] CreatedAuditMappings = + [ + ("Accounts", "Account", ["Account.Provisioned"]), + ("Flocks", "Flock", ["Flock.Create"]), + ("BirdMovements", "BirdMovement", []), + ("DailyEntries", "DailyEntry", ["DailyEntry.Create"]), + ("DailyEntryGrades", "DailyEntryGrade", []), + ("EggGrades", "EggGrade", ["EggGrade.Create"]), + ("EggLots", "EggLot", []), + ("Customers", "Customer", ["Customer.Create"]), + ("SalesOrders", "SalesOrder", ["SalesOrder.Create"]), + ("SalesOrderItems", "SalesOrderItem", []), + ("SalesOrderAllocations", "SalesOrderAllocation", []), + ("Payments", "Payment", []), + ("InventoryItems", "InventoryItem", []), + ("InventoryLots", "InventoryLot", []), + ("ExpenseCategories", "ExpenseCategory", []), + ("Expenses", "Expense", ["Expense.Create"]), + ("Products", "Product", ["Product.Create"]), + ("ProductEggGradeMappings", "ProductEggGradeMapping", []), + ("EggUnitConversions", "EggUnitConversion", []), + ("UserRoleAssignments", "UserRoleAssignment", []), + ("FarmLogos", "FarmLogo", ["Account.SetLogo", "Account.SetBanner"]), + ("AspNetUsers", "User", ["User.Create"]) + ]; + + private static readonly (string Table, string EntityType, string[] Actions)[] MutableAuditMappings = + [ + ("Accounts", "Account", + ["Account.UpdateSettings", "Account.Suspend", "Account.Reactivate", "Account.Rename"]), + ("Flocks", "Flock", + ["Flock.Update", "Flock.Deplete", "Flock.Archive", "Flock.Reactivate"]), + ("DailyEntries", "DailyEntry", + ["DailyEntry.Update", "DailyEntry.Submit", "DailyEntry.Adjust", "DailyEntry.Void"]), + ("DailyEntryGrades", "DailyEntryGrade", []), + ("EggGrades", "EggGrade", + ["EggGrade.Update", "EggGrade.Activate", "EggGrade.Deactivate"]), + ("EggLots", "EggLot", ["EggLot.Movement"]), + ("Customers", "Customer", ["Customer.Update"]), + ("SalesOrders", "SalesOrder", + ["SalesOrder.AddItem", "SalesOrder.UpdateItem", "SalesOrder.RemoveItem", + "SalesOrder.Confirm", "SalesOrder.Cancel", "SalesOrder.Void"]), + ("SalesOrderItems", "SalesOrderItem", []), + ("SalesOrderAllocations", "SalesOrderAllocation", []), + ("Payments", "Payment", ["Payment.Void"]), + ("InventoryItems", "InventoryItem", []), + ("InventoryLots", "InventoryLot", []), + ("WaterUsages", "WaterUsage", ["WaterUsage.Correct"]), + ("ExpenseCategories", "ExpenseCategory", ["ExpenseCategory.Update"]), + ("Expenses", "Expense", ["Expense.Adjust"]), + ("Products", "Product", ["Product.Update", "Product.Activate", "Product.Deactivate"]), + ("ProductEggGradeMappings", "ProductEggGradeMapping", []), + ("EggUnitConversions", "EggUnitConversion", ["EggUnitConversion.Update"]), + ("FarmLogos", "FarmLogo", + ["Account.SetLogo", "Account.RemoveLogo", "Account.SetBanner", "Account.RemoveBanner"]), + ("AspNetUsers", "User", + ["User.Update", "User.PasswordSet", "User.PasswordChanged", "User.EmailChanged", + "User.Disabled", "User.Enabled", "User.RoleChanged", "User.BreakGlassReset"]) + ]; + + private static readonly (string Table, string OrderBy)[] ChronologicalTables = + [ + ("SalesOrders", "\"OrderDate\", \"CreatedAtUtc\", \"Id\""), + ("Expenses", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("DailyEntries", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("EggLots", "\"ProductionDate\", \"CreatedAtUtc\", \"Id\""), + ("BirdMovements", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("Payments", "\"PaymentDate\", \"CreatedAtUtc\", \"Id\""), + ("InventoryLots", "\"ReceivedDate\", \"CreatedAtUtc\", \"Id\""), + ("FeedUsages", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("WaterUsages", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("InventoryMovements", "\"Date\", \"CreatedAtUtc\", \"Id\""), + ("EggInventoryMovements", "\"CreatedAtUtc\", \"Id\"") + ]; + + private static readonly string[] CreatedOnlyTables = + [ + "BirdMovements", "FeedUsages", "InventoryMovements", + "EggInventoryMovements", "UserRoleAssignments" + ]; + + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("SET LOCAL lock_timeout = '5s';"); + + foreach (var (table, _, _) in CreatedAuditMappings) + { + migrationBuilder.AddColumn( + name: "CreatedAtUtc", + table: table, + type: "timestamp with time zone", + nullable: true); + } + + foreach (var (table, _, _) in MutableAuditMappings) + { + migrationBuilder.AddColumn( + name: "UpdatedAtUtc", + table: table, + type: "timestamp with time zone", + nullable: true); + } + + foreach (var mapping in CreatedAuditMappings) + BackfillCreatedAtUtc(migrationBuilder, mapping.Table, mapping.EntityType, mapping.Actions); + + foreach (var mapping in MutableAuditMappings) + BackfillUpdatedAtUtc(migrationBuilder, mapping.Table, mapping.EntityType, mapping.Actions); + + foreach (var (table, _, _) in CreatedAuditMappings) + { + migrationBuilder.AlterColumn( + name: "CreatedAtUtc", + table: table, + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + } + + foreach (var (table, _, _) in MutableAuditMappings) + { + migrationBuilder.AlterColumn( + name: "UpdatedAtUtc", + table: table, + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true); + } + + foreach (var (table, orderBy) in ChronologicalTables) + { + AddSequence(migrationBuilder, table, orderBy); + migrationBuilder.CreateIndex( + name: $"IX_{table}_Sequence", + table: table, + column: "Sequence", + unique: true); + } + + migrationBuilder.Sql( + """ + CREATE FUNCTION "StampMutableBusinessRecord"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + stamp timestamp with time zone := clock_timestamp(); + BEGIN + IF TG_OP = 'INSERT' THEN + NEW."CreatedAtUtc" := stamp; + NEW."UpdatedAtUtc" := stamp; + ELSE + NEW."CreatedAtUtc" := OLD."CreatedAtUtc"; + NEW."UpdatedAtUtc" := stamp; + END IF; + RETURN NEW; + END; + $$; + """); + migrationBuilder.Sql( + """ + CREATE FUNCTION "StampCreatedBusinessRecord"() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF TG_OP = 'INSERT' THEN + NEW."CreatedAtUtc" := clock_timestamp(); + ELSE + NEW."CreatedAtUtc" := OLD."CreatedAtUtc"; + END IF; + RETURN NEW; + END; + $$; + """); + + foreach (var (table, _, _) in MutableAuditMappings) + AddTrigger(migrationBuilder, table, "StampMutableBusinessRecord"); + foreach (var table in CreatedOnlyTables) + AddTrigger(migrationBuilder, table, "StampCreatedBusinessRecord"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + foreach (var (table, _, _) in MutableAuditMappings) + DropTrigger(migrationBuilder, table); + foreach (var table in CreatedOnlyTables) + DropTrigger(migrationBuilder, table); + + migrationBuilder.Sql( + """ + DROP FUNCTION "StampMutableBusinessRecord"(); + """); + migrationBuilder.Sql( + """ + DROP FUNCTION "StampCreatedBusinessRecord"(); + """); + + foreach (var (table, _) in ChronologicalTables) + { + migrationBuilder.DropIndex(name: $"IX_{table}_Sequence", table: table); + migrationBuilder.DropColumn(name: "Sequence", table: table); + } + + foreach (var (table, _, _) in MutableAuditMappings) + migrationBuilder.DropColumn(name: "UpdatedAtUtc", table: table); + + foreach (var (table, _, _) in CreatedAuditMappings) + migrationBuilder.DropColumn(name: "CreatedAtUtc", table: table); + } + + private static void BackfillCreatedAtUtc( + MigrationBuilder migrationBuilder, + string table, + string entityType, + string[] actions) + { + var auditTime = actions.Length == 0 + ? $"TIMESTAMPTZ '{UnknownCreatedAtUtc}'" + : $""" + COALESCE(( + SELECT MIN(a."OccurredAtUtc") + FROM "AuditEvents" AS a + WHERE a."EntityType" = '{entityType}' + AND a."AccountId" = row."AccountId" + AND a."EntityId" = row."Id" + AND a."Action" IN ({SqlLiterals(actions)}) + ), TIMESTAMPTZ '{UnknownCreatedAtUtc}') + """; + + migrationBuilder.Sql($""" + UPDATE "{table}" AS row + SET "CreatedAtUtc" = {auditTime}; + """); + } + + private static void BackfillUpdatedAtUtc( + MigrationBuilder migrationBuilder, + string table, + string entityType, + string[] actions) + { + var updateTime = actions.Length == 0 + ? """row."CreatedAtUtc" """ + : $""" + GREATEST( + row."CreatedAtUtc", + COALESCE(( + SELECT MAX(a."OccurredAtUtc") + FROM "AuditEvents" AS a + WHERE a."EntityType" = '{entityType}' + AND a."AccountId" = row."AccountId" + AND a."EntityId" = row."Id" + AND a."Action" IN ({SqlLiterals(actions)}) + ), row."CreatedAtUtc")) + """; + + migrationBuilder.Sql($""" + UPDATE "{table}" AS row + SET "UpdatedAtUtc" = {updateTime}; + """); + } + + private static void AddSequence(MigrationBuilder migrationBuilder, string table, string orderBy) + { + migrationBuilder.Sql($""" + ALTER TABLE "{table}" ADD COLUMN "Sequence" bigint; + """); + migrationBuilder.Sql($""" + WITH ranked AS ( + SELECT "Id", row_number() OVER (ORDER BY {orderBy}) AS sequence + FROM "{table}" + ) + UPDATE "{table}" AS target + SET "Sequence" = ranked.sequence + FROM ranked + WHERE target."Id" = ranked."Id"; + """); + migrationBuilder.Sql($""" + ALTER TABLE "{table}" ALTER COLUMN "Sequence" SET NOT NULL; + """); + migrationBuilder.Sql($""" + ALTER TABLE "{table}" ALTER COLUMN "Sequence" ADD GENERATED ALWAYS AS IDENTITY; + """); + migrationBuilder.Sql($""" + SELECT setval( + pg_get_serial_sequence('"{table}"', 'Sequence'), + COALESCE((SELECT MAX("Sequence") FROM "{table}"), 0) + 1, + false); + """); + } + + private static void AddTrigger(MigrationBuilder migrationBuilder, string table, string function) + { + migrationBuilder.Sql($""" + CREATE TRIGGER "TR_{table}_BusinessRecordTimestamps" + BEFORE INSERT OR UPDATE ON "{table}" + FOR EACH ROW EXECUTE FUNCTION "{function}"(); + """); + } + + private static void DropTrigger(MigrationBuilder migrationBuilder, string table) + { + migrationBuilder.Sql($""" + DROP TRIGGER "TR_{table}_BusinessRecordTimestamps" ON "{table}"; + """); + } + + private static string SqlLiterals(IEnumerable values) => + string.Join(", ", values.Select(value => $"'{value.Replace("'", "''", StringComparison.Ordinal)}'")); +} diff --git a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 1c6d9c757..f6a5b5c56 100644 --- a/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Cluckwork.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -37,6 +37,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DateFormatOverride") .HasMaxLength(32) .HasColumnType("character varying(32)"); @@ -97,6 +101,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("character varying(16)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -164,6 +172,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("FarmId") .HasColumnType("uuid"); @@ -173,6 +185,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -203,6 +219,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("FarmId") .HasColumnType("uuid"); @@ -294,6 +314,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Active") .HasColumnType("boolean"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("EggsPerUnit") .HasColumnType("integer"); @@ -302,6 +326,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("character varying(16)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -327,6 +355,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Active") .HasColumnType("boolean"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("CurrencyCode") .IsRequired() .HasMaxLength(3) @@ -360,6 +392,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("character varying(16)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -379,12 +415,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("EggGradeId") .HasColumnType("uuid"); b.Property("ProductId") .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("EggGradeId"); @@ -418,6 +462,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CrackedGradeId") .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("Date") .HasColumnType("date"); @@ -445,6 +493,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MortalityCount") .HasColumnType("integer"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -453,6 +507,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TotalEggs") .HasColumnType("integer"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -463,6 +521,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "FarmId", "HouseId", "FlockId", "Date") .IsUnique() .HasDatabaseName("IX_DailyEntries_NaturalKey") @@ -481,6 +542,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DailyEntryId") .HasColumnType("uuid"); @@ -490,6 +555,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Quantity") .HasColumnType("integer"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("AccountId"); @@ -515,6 +584,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Active") .HasColumnType("boolean"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DailyEntryKind") .IsRequired() .HasMaxLength(16) @@ -539,6 +612,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SortOrder") .HasColumnType("integer"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -563,6 +640,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid"); b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone"); b.Property("EggLotId") @@ -588,10 +666,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.HasKey("Id"); b.HasIndex("EggLotId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "EggLotId", "CreatedAtUtc"); b.ToTable("EggInventoryMovements"); @@ -607,6 +694,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DailyEntryId") .HasColumnType("uuid"); @@ -628,6 +719,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RestrictedUntil") .HasColumnType("date"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -639,6 +740,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("EggGradeId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "EggGradeId", "ProductionDate", "QuantityAvailable") .HasDatabaseName("IX_EggLots_Allocation"); @@ -658,6 +762,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AmountMinorUnits") .HasColumnType("bigint"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("CurrencyCode") .IsRequired() .HasMaxLength(3) @@ -687,6 +795,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -697,6 +815,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("FlockId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "Date"); b.HasIndex("AccountId", "ExpenseCategoryId"); @@ -717,6 +838,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Active") .HasColumnType("boolean"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("FarmId") .HasColumnType("uuid"); @@ -725,6 +850,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -744,6 +873,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DailyEntryId") .HasColumnType("uuid"); @@ -760,6 +893,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Quantity") .HasColumnType("integer"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Type") .IsRequired() .HasMaxLength(16) @@ -771,6 +910,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("FlockId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "FlockId", "Date"); b.ToTable("BirdMovements"); @@ -794,6 +936,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("DepletedOn") .HasColumnType("date"); @@ -819,6 +965,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -840,7 +990,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); - b.Property("CreatedAtUtc") + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone"); b.Property("DailyEntryId") @@ -863,6 +1014,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasPrecision(18, 3) .HasColumnType("numeric(18,3)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Unit") .IsRequired() .HasMaxLength(20) @@ -876,6 +1033,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DailyEntryId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("FlockId", "Date"); b.HasIndex("InventoryItemId", "Date"); @@ -901,6 +1061,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("FarmId") .HasColumnType("uuid"); @@ -914,6 +1078,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(20) .HasColumnType("character varying(20)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -935,6 +1103,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("ExpiryDate") .HasColumnType("date"); @@ -956,12 +1128,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReceivedDate") .HasColumnType("date"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); b.HasKey("Id"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("InventoryItemId", "ReceivedDate"); b.ToTable("InventoryLots"); @@ -977,7 +1162,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); - b.Property("CreatedAtUtc") + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone"); b.Property("Date") @@ -1007,6 +1193,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Type") .IsRequired() .HasMaxLength(32) @@ -1023,6 +1215,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("InventoryLotId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("InventoryItemId", "Date"); b.ToTable("InventoryMovements"); @@ -1038,7 +1233,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); - b.Property("CreatedAtUtc") + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone"); b.Property("DailyEntryId") @@ -1066,6 +1262,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasPrecision(18, 3) .HasColumnType("numeric(18,3)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Source") .IsRequired() .HasMaxLength(32) @@ -1076,6 +1278,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(8) .HasColumnType("character varying(8)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -1084,6 +1290,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DailyEntryId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("FlockId", "Date"); b.ToTable("WaterUsages"); @@ -1103,6 +1312,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("Email") .HasMaxLength(254) .HasColumnType("character varying(254)"); @@ -1121,6 +1334,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -1145,6 +1362,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AmountMinorUnits") .HasColumnType("bigint"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("CurrencyCode") .IsRequired() .HasMaxLength(3) @@ -1175,6 +1396,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SalesOrderId") .HasColumnType("uuid"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -1192,6 +1423,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SalesOrderId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "CustomerId"); b.HasIndex("AccountId", "SalesOrderId"); @@ -1209,6 +1443,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("CustomerId") .HasColumnType("uuid"); @@ -1228,11 +1466,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + b.Property("Status") .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("Version") .IsConcurrencyToken() .HasColumnType("integer"); @@ -1245,6 +1493,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CustomerId"); + b.HasIndex("Sequence") + .IsUnique(); + b.HasIndex("AccountId", "ReferenceNumber") .IsUnique(); @@ -1261,6 +1512,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("uuid"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("EggLotId") .HasColumnType("uuid"); @@ -1276,6 +1531,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SalesOrderItemId") .HasColumnType("uuid"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("EggLotId"); @@ -1300,6 +1559,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("BaseUnitFactor") .HasColumnType("integer"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("EggGradeId") .HasColumnType("uuid"); @@ -1333,6 +1596,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(16) .HasColumnType("character varying(16)"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("EggGradeId"); @@ -1388,6 +1655,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("text"); + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + b.Property("CredentialEpoch") .ValueGeneratedOnAdd() .HasColumnType("integer") @@ -1457,6 +1728,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TwoFactorEnabled") .HasColumnType("boolean"); + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone"); + b.Property("UserName") .IsRequired() .HasMaxLength(256) diff --git a/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs index 81d5e6c5f..b2d9018a8 100644 --- a/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/BirdMovementRepository.cs @@ -16,7 +16,7 @@ public async Task> ListByFlockAsync( await db.BirdMovements .AsNoTracking() .Where(m => m.FlockId == flockId) - .OrderByDescending(m => m.Date).ThenByDescending(m => m.Id) + .OrderByBusinessChronologyDescending(m => m.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/DailyEntryRepository.cs b/src/Cluckwork.Infrastructure/Repositories/DailyEntryRepository.cs index 2d3aa01e4..3e9369406 100644 --- a/src/Cluckwork.Infrastructure/Repositories/DailyEntryRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/DailyEntryRepository.cs @@ -72,9 +72,7 @@ await db.DailyEntries .Where(e => (flockId == null || e.FlockId == flockId) && (from == null || e.Date >= from) && (to == null || e.Date <= to)) - // Id tiebreaker: Date alone is non-unique, and unstable ordering under - // OFFSET paging drops or duplicates rows across pages. - .OrderByDescending(e => e.Date).ThenByDescending(e => e.Id) + .OrderByBusinessChronologyDescending(e => e.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/EggInventoryMovementRepository.cs b/src/Cluckwork.Infrastructure/Repositories/EggInventoryMovementRepository.cs index a746cf5df..92d121235 100644 --- a/src/Cluckwork.Infrastructure/Repositories/EggInventoryMovementRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/EggInventoryMovementRepository.cs @@ -18,6 +18,6 @@ public async Task> ListByLotAsync( await db.EggInventoryMovements .AsNoTracking() .Where(m => m.EggLotId == eggLotId) - .OrderByDescending(m => m.CreatedAtUtc).ThenByDescending(m => m.Id) + .OrderByCreationChronologyDescending() .ToListAsync(ct); } diff --git a/src/Cluckwork.Infrastructure/Repositories/EggLotRepository.cs b/src/Cluckwork.Infrastructure/Repositories/EggLotRepository.cs index b9c7c29fe..20a60a76f 100644 --- a/src/Cluckwork.Infrastructure/Repositories/EggLotRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/EggLotRepository.cs @@ -18,7 +18,7 @@ await db.EggLots .Where(l => eggGradeId == null || l.EggGradeId == eggGradeId) .Where(l => from == null || l.ProductionDate >= from) .Where(l => to == null || l.ProductionDate <= to) - .OrderByDescending(l => l.ProductionDate).ThenByDescending(l => l.Id) + .OrderByBusinessChronologyDescending(l => l.ProductionDate) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs b/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs index 59d5eed87..89f71e4cf 100644 --- a/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs +++ b/src/Cluckwork.Infrastructure/Repositories/ExpenseRepositories.cs @@ -59,9 +59,7 @@ public async Task> ListAsync( CancellationToken ct = default) => await Filtered(from, to, categoryId) .AsNoTracking() - // Id tiebreaker: Date alone is non-unique, and unstable ordering - // under OFFSET paging drops or duplicates rows across pages. - .OrderByDescending(e => e.Date).ThenByDescending(e => e.Id) + .OrderByBusinessChronologyDescending(e => e.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/ExportQueries.cs b/src/Cluckwork.Infrastructure/Repositories/ExportQueries.cs index 25a4e56e1..e8a91448b 100644 --- a/src/Cluckwork.Infrastructure/Repositories/ExportQueries.cs +++ b/src/Cluckwork.Infrastructure/Repositories/ExportQueries.cs @@ -114,12 +114,12 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.InitialCount, x.Status, x.DepletedOn, x.ArchivedOn, x.Version]), "bird-movements" => Rows(activeDb.BirdMovements.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), ["id", "flockId", "date", "type", "quantity", "note", "dailyEntryId"], x => [x.Id, x.FlockId, x.Date, x.Type, x.Quantity, x.Note, x.DailyEntryId]), "daily-entries" => Rows(activeDb.DailyEntries.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), // #396 — the two snapshot ids ride next to the counters they // explain. Without them an export records that a day had 40 // cracked eggs but not whether those became saleable stock or a @@ -151,7 +151,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.DailyEntryKind, x.Active, x.Version]), "egg-lots" => Rows(activeDb.EggLots.AsNoTracking() - .OrderBy(x => x.ProductionDate).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.ProductionDate), ["id", "flockId", "productionDate", "eggGradeId", "quantityProduced", "quantityAvailable", "dailyEntryId", "restrictedUntil", "version"], x => [x.Id, x.FlockId, x.ProductionDate, x.EggGradeId, x.QuantityProduced, @@ -166,7 +166,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo // export of the table, and the reason is a column on it. The // discount TOTALS a report would show stay with #725. "sales-orders" => Rows(activeDb.SalesOrders.AsNoTracking() - .OrderBy(x => x.OrderDate).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.OrderDate), ["id", "referenceNumber", "customerId", "status", "orderDate", "totalMinorUnits", "currencyCode", "currencyMinorUnit", "voidReason", "discountReasonCode", "discountReasonNote", "version"], @@ -196,7 +196,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x => [x.Id, x.SalesOrderId, x.SalesOrderItemId, x.EggLotId, x.Quantity, x.ReleasedOnUtc]), "payments" => Rows(activeDb.Payments.AsNoTracking() - .OrderBy(x => x.PaymentDate).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.PaymentDate), ["id", "salesOrderId", "customerId", "paymentDate", "amountMinorUnits", "currencyCode", "currencyMinorUnit", "method", "referenceNumber", "note", "voided", "voidReason", "version"], @@ -212,7 +212,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.Active, x.Version]), "inventory-lots" => Rows(activeDb.InventoryLots.AsNoTracking() - .OrderBy(x => x.ReceivedDate).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.ReceivedDate), ["id", "inventoryItemId", "receivedDate", "lotNumber", "expiryDate", "quantityReceived", "quantityAvailable", "unitCostMinorUnits", "currencyCode", "currencyMinorUnit", "version"], @@ -221,14 +221,14 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.UnitCost.CurrencyCode, x.UnitCost.CurrencyMinorUnit, x.Version]), "inventory-movements" => Rows(activeDb.InventoryMovements.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.CreatedAtUtc).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), ["id", "inventoryItemId", "inventoryLotId", "date", "type", "quantityDelta", "unit", "flockId", "note", "createdAtUtc", "referenceType", "referenceId"], x => [x.Id, x.InventoryItemId, x.InventoryLotId, x.Date, x.Type, x.QuantityDelta, x.Unit, x.FlockId, x.Note, x.CreatedAtUtc, x.ReferenceType, x.ReferenceId]), "feed-usages" => Rows(activeDb.FeedUsages.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), ["id", "flockId", "inventoryItemId", "date", "quantity", "unit", "estimatedCostMinorUnits", "currencyCode", "currencyMinorUnit", "dailyEntryId", "note", "createdAtUtc", "version"], @@ -238,7 +238,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.CreatedAtUtc, x.Version]), "water-usages" => Rows(activeDb.WaterUsages.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), ["id", "flockId", "date", "quantity", "unit", "source", "meterStart", "meterEnd", "note", "dailyEntryId", "createdAtUtc", "version"], x => [x.Id, x.FlockId, x.Date, x.Quantity, x.Unit, x.Source, x.MeterStart, @@ -250,7 +250,7 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x => [x.Id, x.FarmId, x.Name, x.Active, x.Version]), "expenses" => Rows(activeDb.Expenses.AsNoTracking() - .OrderBy(x => x.Date).ThenBy(x => x.Id), + .OrderByBusinessChronology(x => x.Date), ["id", "farmId", "expenseCategoryId", "date", "description", "amountMinorUnits", "currencyCode", "currencyMinorUnit", "flockId", "note", "version"], @@ -259,14 +259,15 @@ private sealed class AsyncDisposableAction(Func action) : IAsyncDispo x.FlockId, x.Note, x.Version]), "egg-inventory-movements" => Rows(activeDb.EggInventoryMovements.AsNoTracking() - .OrderBy(x => x.CreatedAtUtc).ThenBy(x => x.Id), + .OrderByCreationChronology(), ["id", "eggLotId", "movementType", "quantityDelta", "referenceType", "referenceId", "reason", "createdAtUtc"], x => [x.Id, x.EggLotId, x.MovementType, x.QuantityDelta, x.ReferenceType, x.ReferenceId, x.Reason, x.CreatedAtUtc]), "audit-events" => Rows(activeDb.AuditEvents.AsNoTracking() - .OrderBy(x => x.OccurredAtUtc).ThenBy(x => x.Id), + .OrderBy(x => x.OccurredAtUtc) + .ThenBy(x => EF.Property(x, "Sequence")), ["id", "occurredAtUtc", "actorUserId", "actorEmail", "action", "entityType", "entityId", "reason", "detailsJson"], x => [x.Id, x.OccurredAtUtc, x.ActorUserId, x.ActorEmail, x.Action, diff --git a/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs b/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs index 98eb08941..d819ee1f6 100644 --- a/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/FeedUsageRepository.cs @@ -18,7 +18,7 @@ await db.FeedUsages .Where(u => (flockId == null || u.FlockId == flockId) && (from == null || u.Date >= from) && (to == null || u.Date <= to)) - .OrderByDescending(u => u.Date).ThenByDescending(u => u.CreatedAtUtc).ThenByDescending(u => u.Id) + .OrderByBusinessChronologyDescending(u => u.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs b/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs index f4d0d47d7..ed22c7372 100644 --- a/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/InventoryLotRepository.cs @@ -51,7 +51,7 @@ public async Task> ListByItemAsync( await db.InventoryLots .AsNoTracking() .Where(l => l.InventoryItemId == inventoryItemId) - .OrderByDescending(l => l.ReceivedDate).ThenByDescending(l => l.Id) + .OrderByBusinessChronologyDescending(l => l.ReceivedDate) .ToListAsync(ct); public async Task> StockByItemAsync(CancellationToken ct = default) => diff --git a/src/Cluckwork.Infrastructure/Repositories/InventoryMovementRepository.cs b/src/Cluckwork.Infrastructure/Repositories/InventoryMovementRepository.cs index 90773ea59..c55b838ce 100644 --- a/src/Cluckwork.Infrastructure/Repositories/InventoryMovementRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/InventoryMovementRepository.cs @@ -15,9 +15,7 @@ public async Task> ListByItemAsync( await db.InventoryMovements .AsNoTracking() .Where(m => m.InventoryItemId == inventoryItemId) - // CreatedAtUtc tiebreak: same-day rows order by when they were - // appended, not by random id. - .OrderByDescending(m => m.Date).ThenByDescending(m => m.CreatedAtUtc).ThenByDescending(m => m.Id) + .OrderByBusinessChronologyDescending(m => m.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs b/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs index 1ce7d1900..0c1288fd0 100644 --- a/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/PaymentRepository.cs @@ -16,8 +16,7 @@ public async Task> ListByOrderAsync( await db.Payments .AsNoTracking() .Where(p => p.SalesOrderId == salesOrderId) - // Id tiebreaker keeps same-day payments in a stable order. - .OrderBy(p => p.PaymentDate).ThenBy(p => p.Id) + .OrderByBusinessChronology(p => p.PaymentDate) .ToListAsync(ct); public async Task SumNonVoidedByOrderAsync( diff --git a/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs b/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs index 37c8187b6..28d1fa051 100644 --- a/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/SalesOrderRepository.cs @@ -60,7 +60,7 @@ public string ListQuerySql(SalesOrderListFilter filter, int limit, int offset) = // Locals, not filter.X in the expression tree: EF parameterises a captured // member the same way it parameterises a local, and keeping the shape - // identical to the pre-#769 query keeps its emitted SQL identical too. + // identical to the pre-#769 query keeps its emitted predicates identical. private IQueryable Filtered(SalesOrderListFilter filter) { var status = filter.Status; @@ -76,12 +76,13 @@ private IQueryable Filtered(SalesOrderListFilter filter) && (to == null || o.OrderDate <= to)); } - // #769 SettlementScope.Hidden — the pre-#769 query, untouched. A caller - // outside the money tier gets SQL that never names Payments; the null - // outstanding is then a fact about the query, not a field blanked after it. + // #769 SettlementScope.Hidden — the pre-#769 query, with only #819's + // chronological tiebreak changed. A caller outside the money tier gets SQL + // that never names Payments; the null outstanding is then a fact about the + // query, not a field blanked after it. private IQueryable HiddenPage(SalesOrderListFilter filter, int limit, int offset) => Filtered(filter) - .OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id) + .OrderByBusinessChronologyDescending(o => o.OrderDate) .Skip(offset) .Take(limit); @@ -112,7 +113,7 @@ private IQueryable SettlementPage( // without complaint, so the figure below is still written ONCE and the // filter is literally that same expression. var rows = Filtered(filter) - .OrderByDescending(o => o.OrderDate).ThenByDescending(o => o.Id) + .OrderByBusinessChronologyDescending(o => o.OrderDate) .Select(o => new { Order = o, diff --git a/src/Cluckwork.Infrastructure/Repositories/WaterUsageRepository.cs b/src/Cluckwork.Infrastructure/Repositories/WaterUsageRepository.cs index 6bfa118d6..8247cba36 100644 --- a/src/Cluckwork.Infrastructure/Repositories/WaterUsageRepository.cs +++ b/src/Cluckwork.Infrastructure/Repositories/WaterUsageRepository.cs @@ -18,7 +18,7 @@ await db.WaterUsages .Where(u => (flockId == null || u.FlockId == flockId) && (from == null || u.Date >= from) && (to == null || u.Date <= to)) - .OrderByDescending(u => u.Date).ThenByDescending(u => u.CreatedAtUtc).ThenByDescending(u => u.Id) + .OrderByBusinessChronologyDescending(u => u.Date) .Skip(offset) .Take(limit) .ToListAsync(ct); diff --git a/tests/Cluckwork.Api.IntegrationTests/AccountProvisioningTests.cs b/tests/Cluckwork.Api.IntegrationTests/AccountProvisioningTests.cs index fc1243102..79f082b34 100644 --- a/tests/Cluckwork.Api.IntegrationTests/AccountProvisioningTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/AccountProvisioningTests.cs @@ -48,6 +48,7 @@ public async Task Provision_CreatesAFarmWithReferenceDataAndAnOwner() new HashSet(StringComparer.Ordinal) { nameof(EggGrade.Id), nameof(EggGrade.AccountId), nameof(EggGrade.Version), + nameof(EggGrade.CreatedAtUtc), nameof(EggGrade.UpdatedAtUtc), }); var conversions = await db.EggUnitConversions.IgnoreQueryFilters() .Where(conversion => conversion.AccountId == outcome.AccountId) @@ -62,6 +63,8 @@ public async Task Provision_CreatesAFarmWithReferenceDataAndAnOwner() nameof(EggUnitConversion.Id), nameof(EggUnitConversion.AccountId), nameof(EggUnitConversion.Version), + nameof(EggUnitConversion.CreatedAtUtc), + nameof(EggUnitConversion.UpdatedAtUtc), }); var owner = await db.Users.SingleAsync(u => u.AccountId == outcome.AccountId && u.Email == email); diff --git a/tests/Cluckwork.Api.IntegrationTests/BaseReferenceDataMigrationTests.cs b/tests/Cluckwork.Api.IntegrationTests/BaseReferenceDataMigrationTests.cs index f47a3c32a..3e8bfe7ea 100644 --- a/tests/Cluckwork.Api.IntegrationTests/BaseReferenceDataMigrationTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/BaseReferenceDataMigrationTests.cs @@ -99,11 +99,13 @@ public async Task MigratingAVirginDatabase_ProducesExactlyOneOfEachReferenceRow( nameof(Account.Slug), nameof(Account.Locale), nameof(Account.DefaultCurrencyCode), + nameof(Account.CreatedAtUtc), + nameof(Account.UpdatedAtUtc), }; ReferenceDataComparison.AssertExactMappedPropertyPartition( accountEntityType, accountComparedProperties, accountExcludedProperties); Assert.Equal(11, accountComparedProperties.Count); - Assert.Equal(8, accountExcludedProperties.Count); + Assert.Equal(10, accountExcludedProperties.Count); var actualAccount = Assert.Single(await db.Accounts.IgnoreQueryFilters() .Where(account => account.Id == SeedDefaults.AccountId) @@ -135,6 +137,7 @@ public async Task MigratingAVirginDatabase_ProducesExactlyOneOfEachReferenceRow( new HashSet(StringComparer.Ordinal) { nameof(EggGrade.Id), nameof(EggGrade.AccountId), nameof(EggGrade.Version), + nameof(EggGrade.CreatedAtUtc), nameof(EggGrade.UpdatedAtUtc), }); Assert.NotNull(db.Model.FindEntityType(typeof(EggUnitConversion))); @@ -152,6 +155,8 @@ public async Task MigratingAVirginDatabase_ProducesExactlyOneOfEachReferenceRow( nameof(EggUnitConversion.Id), nameof(EggUnitConversion.AccountId), nameof(EggUnitConversion.Version), + nameof(EggUnitConversion.CreatedAtUtc), + nameof(EggUnitConversion.UpdatedAtUtc), }); } diff --git a/tests/Cluckwork.Api.IntegrationTests/BusinessRecordChronologyMigrationTests.cs b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordChronologyMigrationTests.cs new file mode 100644 index 000000000..f3a80f62a --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordChronologyMigrationTests.cs @@ -0,0 +1,192 @@ +namespace Cluckwork.Api.IntegrationTests; + +using Cluckwork.Domain.Accounts; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Sales; +using Cluckwork.Application.Features.Sales; +using Cluckwork.Infrastructure.Persistence; +using Cluckwork.Infrastructure.Persistence.Interceptors; +using Cluckwork.Infrastructure.Providers; +using Cluckwork.Infrastructure.Providers.Postgres; +using Cluckwork.Infrastructure.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Testcontainers.PostgreSql; + +public sealed class BusinessRecordChronologyMigrationTests +{ + private const string PreviousMigration = "20260911135948_AddAccountMaxDiscountBasisPoints"; + private const string PostgresImage = + "postgres:18.4-trixie@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a"; + + [Fact] + public async Task Upgrade_uses_exact_audits_and_marks_unknown_legacy_times() + { + await using var postgres = new PostgreSqlBuilder(PostgresImage).Build(); + await postgres.StartAsync(); + await using var db = BuildContext(postgres.GetConnectionString()); + var migrator = db.Database.GetService(); + await migrator.MigrateAsync(PreviousMigration); + + var accountId = SeedDefaults.AccountId; + var auditedCustomerId = Guid.NewGuid(); + var unknownCustomerId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var olderOrderId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + var newerOrderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + var createdAt = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero); + var updatedAt = createdAt.AddHours(1); + var newerOrderCreatedAt = createdAt.AddMinutes(1); + + await InsertCustomerAsync(auditedCustomerId, accountId, "Audited"); + await InsertCustomerAsync(unknownCustomerId, accountId, "Unknown"); + await InsertUserAsync(userId, accountId); + await InsertOrderAsync(olderOrderId, accountId, auditedCustomerId, "SO-OLDER"); + await InsertOrderAsync(newerOrderId, accountId, auditedCustomerId, "SO-NEWER"); + await InsertAuditAsync("Customer.Create", "Customer", auditedCustomerId, createdAt); + await InsertAuditAsync("Customer.Update", "Customer", auditedCustomerId, updatedAt); + await InsertAuditAsync("Customer.Read", "Customer", auditedCustomerId, updatedAt.AddHours(1)); + await InsertAuditAsync("User.Create", "User", userId, createdAt); + await InsertAuditAsync("User.RoleChanged", "User", userId, updatedAt); + await InsertAuditAsync("SalesOrder.Create", "SalesOrder", olderOrderId, createdAt); + await InsertAuditAsync("SalesOrder.Create", "SalesOrder", newerOrderId, newerOrderCreatedAt); + + await migrator.MigrateAsync(); + + var timestampTriggerCount = await db.Database.SqlQueryRaw( + """ + SELECT count(*)::integer AS "Value" + FROM pg_trigger AS trigger + INNER JOIN pg_class AS relation ON relation.oid = trigger.tgrelid + INNER JOIN pg_namespace AS schema ON schema.oid = relation.relnamespace + WHERE NOT trigger.tgisinternal + AND schema.nspname = 'public' + AND trigger.tgname LIKE 'TR\_%\_BusinessRecordTimestamps' ESCAPE '\' + """).SingleAsync(); + var timestampedModelCount = db.Model.GetEntityTypes() + .Count(entity => !entity.IsOwned() + && typeof(ICreatedRecord).IsAssignableFrom(entity.ClrType)); + Assert.Equal(timestampedModelCount, timestampTriggerCount); + + var customers = await db.Customers.IgnoreQueryFilters() + .Where(row => row.Id == auditedCustomerId || row.Id == unknownCustomerId) + .ToDictionaryAsync(row => row.Id); + Assert.Equal(createdAt, customers[auditedCustomerId].CreatedAtUtc); + Assert.Equal(updatedAt, customers[auditedCustomerId].UpdatedAtUtc); + var unknown = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + Assert.Equal(unknown, customers[unknownCustomerId].CreatedAtUtc); + Assert.Equal(unknown, customers[unknownCustomerId].UpdatedAtUtc); + + var baseAccount = await db.Accounts.IgnoreQueryFilters() + .SingleAsync(row => row.Id == SeedDefaults.AccountId); + Assert.Equal(unknown, baseAccount.CreatedAtUtc); + Assert.Equal(unknown, baseAccount.UpdatedAtUtc); + + var user = await db.Users.IgnoreQueryFilters().SingleAsync(row => row.Id == userId); + Assert.Equal(createdAt, user.CreatedAtUtc); + Assert.Equal(updatedAt, user.UpdatedAtUtc); + + var orders = await db.SalesOrders.IgnoreQueryFilters() + .Where(row => row.Id == olderOrderId || row.Id == newerOrderId) + .OrderBy(row => EF.Property(row, "Sequence")) + .Select(row => new + { + row.Id, + row.CreatedAtUtc, + Sequence = EF.Property(row, "Sequence"), + }) + .ToListAsync(); + Assert.Equal([olderOrderId, newerOrderId], orders.Select(row => row.Id)); + Assert.Equal([createdAt, newerOrderCreatedAt], orders.Select(row => row.CreatedAtUtc)); + Assert.Equal([1L, 2L], orders.Select(row => row.Sequence)); + + // Legacy rows without creation audits all receive the same explicit + // unknown timestamp. This repository assertion therefore exercises + // Sequence as the final ordering key, rather than merely proving that + // CreatedAtUtc replaced the former random-Guid tiebreak. + Guid[] tiedOrderIds = + [ + Guid.Parse("80000000-0000-0000-0000-000000000008"), + Guid.Parse("10000000-0000-0000-0000-000000000001"), + Guid.Parse("70000000-0000-0000-0000-000000000007"), + Guid.Parse("20000000-0000-0000-0000-000000000002"), + Guid.Parse("60000000-0000-0000-0000-000000000006"), + Guid.Parse("30000000-0000-0000-0000-000000000003"), + Guid.Parse("50000000-0000-0000-0000-000000000005"), + Guid.Parse("40000000-0000-0000-0000-000000000004"), + ]; + for (var index = 0; index < tiedOrderIds.Length; index++) + await InsertOrderAsync(tiedOrderIds[index], accountId, auditedCustomerId, $"SO-TIE-{index}"); + + // Inserts above run after the migration and therefore receive live + // timestamps. Pin all to the legitimate legacy sentinel by disabling + // only the timestamp trigger for this setup operation. + await db.Database.ExecuteSqlRawAsync( + """ + ALTER TABLE "SalesOrders" DISABLE TRIGGER "TR_SalesOrders_BusinessRecordTimestamps"; + UPDATE "SalesOrders" + SET "CreatedAtUtc" = TIMESTAMPTZ '1970-01-01 00:00:00+00' + WHERE "ReferenceNumber" LIKE 'SO-TIE-%'; + ALTER TABLE "SalesOrders" ENABLE TRIGGER "TR_SalesOrders_BusinessRecordTimestamps"; + """); + + var tenant = new TenantContext(); + tenant.Resolve(accountId); + await using var listDb = BuildContext(postgres.GetConnectionString(), tenant); + var list = await new SalesOrderRepository(listDb).ListAsync( + new SalesOrderListFilter(null, null, null, null, SettlementScope.Hidden), 100, 0); + Assert.Equal( + tiedOrderIds.Reverse(), + list.Select(row => row.Order.Id) + .Where(tiedOrderIds.Contains) + .ToArray()); + + async Task InsertCustomerAsync(Guid id, Guid tenantId, string name) => + await db.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO "Customers" ("Id", "Name", "Phone", "AccountId", "Version") + VALUES ({id}, {name}, '1', {tenantId}, 0) + """); + + async Task InsertOrderAsync(Guid id, Guid tenantId, Guid customerId, string reference) => + await db.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO "SalesOrders" + ("Id", "ReferenceNumber", "CustomerId", "Status", "OrderDate", + "TotalMinorUnits", "TotalCurrencyCode", "TotalCurrencyMinorUnit", + "Version", "AccountId") + VALUES ({id}, {reference}, {customerId}, 'Draft', DATE '2025-01-02', + 0, 'USD', 2, 0, {tenantId}) + """); + + async Task InsertUserAsync(Guid id, Guid tenantId) => + await db.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO "AspNetUsers" + ("Id", "AccountId", "MustChangePassword", "UserName", "NormalizedUserName", + "Email", "NormalizedEmail", "EmailConfirmed", "PhoneNumberConfirmed", + "TwoFactorEnabled", "LockoutEnabled", "AccessFailedCount", "CredentialEpoch", + "StepUpLogoutEpoch") + VALUES ({id}, {tenantId}, FALSE, 'migration-user@test.local', + 'MIGRATION-USER@TEST.LOCAL', 'migration-user@test.local', + 'MIGRATION-USER@TEST.LOCAL', FALSE, FALSE, FALSE, FALSE, 0, 1, 0) + """); + + async Task InsertAuditAsync(string action, string entityType, Guid entityId, DateTimeOffset occurredAt) => + await db.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO "AuditEvents" + ("Id", "OccurredAtUtc", "ActorUserId", "ActorEmail", "Action", + "EntityType", "EntityId", "AccountId") + VALUES ({Guid.NewGuid()}, {occurredAt}, {Guid.NewGuid()}, 'migration@test.local', + {action}, {entityType}, {entityId}, {accountId}) + """); + } + + private static AppDbContext BuildContext(string connectionString, TenantContext? tenant = null) + { + var options = new DbContextOptionsBuilder(); + new PostgresDbContextConfigurator().Configure( + options, connectionString, new DatabaseResilienceOptions()); + var activeTenant = tenant ?? new TenantContext(); + options.AddInterceptors(new TenantStampInterceptor(activeTenant)); + return new AppDbContext(options.Options, activeTenant, new FlockScope()); + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/BusinessRecordModelTests.cs b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordModelTests.cs new file mode 100644 index 000000000..2d2bce655 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordModelTests.cs @@ -0,0 +1,95 @@ +namespace Cluckwork.Api.IntegrationTests; + +using Cluckwork.Domain.Accounts; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Eggs; +using Cluckwork.Domain.Expenses; +using Cluckwork.Domain.Flocks; +using Cluckwork.Domain.Inventory; +using Cluckwork.Domain.Sales; +using Cluckwork.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +public sealed class BusinessRecordModelTests +{ + private static readonly Type[] CreatedOnlyTypes = + [ + typeof(UserRoleAssignment), typeof(BirdMovement), typeof(FeedUsage), + typeof(InventoryMovement), typeof(EggInventoryMovement) + ]; + + private static readonly Type[] ChronologicalListTypes = + [ + typeof(SalesOrder), typeof(Expense), typeof(DailyEntry), typeof(EggLot), + typeof(BirdMovement), typeof(Payment), typeof(InventoryLot), typeof(FeedUsage), + typeof(WaterUsage), typeof(InventoryMovement), typeof(EggInventoryMovement) + ]; + + private static AppDbContext BuildContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=model-only;Username=none;Password=none") + .Options; + return new AppDbContext(options, new TenantContext(), new FlockScope()); + } + + [Fact] + public void Business_records_have_the_declared_timestamp_shape() + { + using var db = BuildContext(); + + var timestampedTypes = db.Model.GetEntityTypes() + .Where(entity => !entity.IsOwned() + && typeof(ICreatedRecord).IsAssignableFrom(entity.ClrType)) + .Select(entity => entity.ClrType) + .ToArray(); + + foreach (var type in timestampedTypes) + { + var entity = db.Model.FindEntityType(type); + Assert.NotNull(entity); + Assert.True(typeof(ICreatedRecord).IsAssignableFrom(type), $"{type.Name} is missing ICreatedRecord"); + + var createdAt = entity.FindProperty(nameof(ICreatedRecord.CreatedAtUtc)); + Assert.NotNull(createdAt); + Assert.Equal(typeof(DateTimeOffset), createdAt.ClrType); + Assert.False(createdAt.IsNullable); + + var mutable = !CreatedOnlyTypes.Contains(type); + Assert.Equal(mutable, typeof(IMutableRecord).IsAssignableFrom(type)); + Assert.Equal(mutable, entity.FindProperty(nameof(IMutableRecord.UpdatedAtUtc)) is not null); + } + + Assert.Equal( + CreatedOnlyTypes.OrderBy(type => type.Name), + timestampedTypes.Where(type => !typeof(IMutableRecord).IsAssignableFrom(type)) + .OrderBy(type => type.Name)); + } + + [Fact] + public void Only_chronological_lists_have_a_unique_generated_sequence() + { + using var db = BuildContext(); + + var timestampedTypes = db.Model.GetEntityTypes() + .Where(entity => !entity.IsOwned() + && typeof(ICreatedRecord).IsAssignableFrom(entity.ClrType)) + .Select(entity => entity.ClrType); + + foreach (var type in timestampedTypes) + { + var entity = db.Model.FindEntityType(type)!; + var sequence = entity.FindProperty("Sequence"); + var chronological = ChronologicalListTypes.Contains(type); + + Assert.Equal(chronological, sequence is not null); + if (!chronological) continue; + + Assert.Equal(typeof(long), sequence!.ClrType); + Assert.Equal(ValueGenerated.OnAdd, sequence.ValueGenerated); + Assert.Contains(entity.GetIndexes(), index => + index.IsUnique && index.Properties.Count == 1 && index.Properties[0] == sequence); + } + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/BusinessRecordTimestampTests.cs b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordTimestampTests.cs new file mode 100644 index 000000000..fa16adfd3 --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/BusinessRecordTimestampTests.cs @@ -0,0 +1,47 @@ +namespace Cluckwork.Api.IntegrationTests; + +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Domain.Sales; +using Microsoft.EntityFrameworkCore; + +[Collection(IntegrationCollection.Name)] +public sealed class BusinessRecordTimestampTests(CluckworkWebApplicationFactory factory) +{ + [Fact] + public async Task Database_stamps_creation_and_tracked_or_bulk_updates() + { + var accountId = await factory.SeedAccountWithUserAsync( + $"timestamps-{Guid.NewGuid():N}@test.local"); + + await factory.WithTenantScopeAsync(accountId, async db => + { + var customer = Customer.Create(Guid.NewGuid(), accountId, "Timestamp customer", "1"); + db.Customers.Add(customer); + await db.SaveChangesAsync(); + + Assert.NotEqual(default, customer.CreatedAtUtc); + Assert.Equal(customer.CreatedAtUtc, customer.UpdatedAtUtc); + var createdAt = customer.CreatedAtUtc; + + await Task.Delay(5); + Assert.True(customer.Update("Timestamp customer", "2", null, null, null).IsSuccess); + await db.SaveChangesAsync(); + + Assert.Equal(createdAt, customer.CreatedAtUtc); + Assert.True(customer.UpdatedAtUtc > createdAt); + var trackedUpdateAt = customer.UpdatedAtUtc; + + await Task.Delay(5); + await db.Customers + .Where(row => row.Id == customer.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(row => row.Phone, "3") + .SetProperty(row => row.Version, row => row.Version + 1)); + + db.ChangeTracker.Clear(); + var reloaded = await db.Customers.SingleAsync(row => row.Id == customer.Id); + Assert.Equal(createdAt, reloaded.CreatedAtUtc); + Assert.True(reloaded.UpdatedAtUtc > trackedUpdateAt); + }); + } +} diff --git a/tests/Cluckwork.Api.IntegrationTests/EggLotWriteOffTests.cs b/tests/Cluckwork.Api.IntegrationTests/EggLotWriteOffTests.cs index 19f4f9b36..01f00f2ac 100644 --- a/tests/Cluckwork.Api.IntegrationTests/EggLotWriteOffTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/EggLotWriteOffTests.cs @@ -41,20 +41,24 @@ public async Task Discard_RemovesStock_ProductionUntouched_LedgerBalances() Assert.Equal(90, payload.GetProperty("quantityAvailable").GetInt32()); Assert.Equal("Discard", payload.GetProperty("movementType").GetString()); Assert.Equal(-10, payload.GetProperty("quantityDelta").GetInt32()); + var createdAtUtc = payload.GetProperty("createdAtUtc").GetDateTimeOffset(); + Assert.NotEqual(default, createdAtUtc); - var (produced, available, ledgerSum, discardCount) = + var (produced, available, ledgerSum, discardCount, persistedCreatedAtUtc) = await factory.WithTenantScopeAsync(accountId, async db => { var lot = await db.EggLots.SingleAsync(l => l.Id == lotId); var movements = await db.EggInventoryMovements.Where(m => m.EggLotId == lotId).ToListAsync(); return (lot.QuantityProduced, lot.QuantityAvailable, movements.Sum(m => m.QuantityDelta), - movements.Count(m => m.MovementType == EggMovementType.Discard)); + movements.Count(m => m.MovementType == EggMovementType.Discard), + movements.Single(m => m.MovementType == EggMovementType.Discard).CreatedAtUtc); }); Assert.Equal(100, produced); // the day's laying is not restated Assert.Equal(90, available); Assert.Equal(available, ledgerSum); // #101 invariant Assert.Equal(1, discardCount); + Assert.Equal(persistedCreatedAtUtc, createdAtUtc); } [Fact] diff --git a/tests/Cluckwork.Api.IntegrationTests/FlockScopeTests.cs b/tests/Cluckwork.Api.IntegrationTests/FlockScopeTests.cs index de154d9e1..fcd64cf5f 100644 --- a/tests/Cluckwork.Api.IntegrationTests/FlockScopeTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/FlockScopeTests.cs @@ -281,12 +281,12 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => lotAId, fix.AccountId, fix.FlockA, Today, fix.GradeId, 11)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( Guid.NewGuid(), fix.AccountId, lotAId, EggMovementType.Production, - 11, "FilterGuard", Guid.NewGuid(), DateTimeOffset.UtcNow)); + 11, "FilterGuard", Guid.NewGuid())); db.EggLots.Add(EggLot.Create( lotBId, fix.AccountId, fix.FlockB, Today, fix.GradeId, 12)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( Guid.NewGuid(), fix.AccountId, lotBId, EggMovementType.Production, - 12, "FilterGuard", Guid.NewGuid(), DateTimeOffset.UtcNow)); + 12, "FilterGuard", Guid.NewGuid())); db.BirdMovements.Add(BirdMovement.Create( birdAId, fix.AccountId, fix.FlockA, Today, @@ -297,15 +297,15 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => var inventoryA = InventoryMovement.Create( fix.AccountId, itemId, inventoryLotId: null, Today, - InventoryMovementType.Adjustment, 1m, "kg", DateTime.UtcNow, + InventoryMovementType.Adjustment, 1m, "kg", flockId: fix.FlockA, note: "filter A"); var inventoryB = InventoryMovement.Create( fix.AccountId, itemId, inventoryLotId: null, Today, - InventoryMovementType.Adjustment, 1m, "kg", DateTime.UtcNow, + InventoryMovementType.Adjustment, 1m, "kg", flockId: fix.FlockB, note: "filter B"); var inventoryFarmWide = InventoryMovement.Create( fix.AccountId, itemId, inventoryLotId: null, Today, - InventoryMovementType.Adjustment, 1m, "kg", DateTime.UtcNow, + InventoryMovementType.Adjustment, 1m, "kg", flockId: null, note: "filter farm-wide"); inventoryAId = inventoryA.Id; inventoryBId = inventoryB.Id; @@ -314,10 +314,10 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => db.FeedUsages.Add(FeedUsage.Create( feedAId, fix.AccountId, fix.FlockA, itemId, Today, - 1m, "kg", Money.Zero("USD"), DateTime.UtcNow)); + 1m, "kg", Money.Zero("USD"))); db.FeedUsages.Add(FeedUsage.Create( feedBId, fix.AccountId, fix.FlockB, itemId, Today, - 1m, "kg", Money.Zero("USD"), DateTime.UtcNow)); + 1m, "kg", Money.Zero("USD"))); await db.SaveChangesAsync(); @@ -410,17 +410,17 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => BirdMovementType.Adjustment, 1, "own tenant guard")); var inventory = InventoryMovement.Create( fix.AccountId, itemId, inventoryLotId: null, Today, - InventoryMovementType.Adjustment, 1m, "kg", DateTime.UtcNow, + InventoryMovementType.Adjustment, 1m, "kg", flockId: fix.FlockA, note: "own tenant guard"); ownInventoryId = inventory.Id; db.InventoryMovements.Add(inventory); db.FeedUsages.Add(FeedUsage.Create( ownFeedId, fix.AccountId, fix.FlockA, itemId, Today, - 1m, "kg", Money.Zero("USD"), DateTime.UtcNow)); + 1m, "kg", Money.Zero("USD"))); db.WaterUsages.Add(WaterUsage.Create( ownWaterId, fix.AccountId, fix.FlockA, Today, 1m, "L", WaterSource.Municipal, - meterStart: null, meterEnd: null, DateTime.UtcNow)); + meterStart: null, meterEnd: null)); db.Expenses.Add(Cluckwork.Domain.Expenses.Expense.Create( ownExpenseId, fix.AccountId, fix.FarmId, categoryId, Today, "Own tenant guard expense", 100, "USD", 2, flockId: fix.FlockA)); @@ -451,17 +451,17 @@ await factory.WithTenantScopeAsync(foreignAccountId, async db => BirdMovementType.Adjustment, 1, "foreign tenant guard")); var inventory = InventoryMovement.Create( foreignAccountId, itemId, inventoryLotId: null, Today, - InventoryMovementType.Adjustment, 1m, "kg", DateTime.UtcNow, + InventoryMovementType.Adjustment, 1m, "kg", flockId: foreignFlockId, note: "foreign tenant guard"); foreignInventoryId = inventory.Id; db.InventoryMovements.Add(inventory); db.FeedUsages.Add(FeedUsage.Create( foreignFeedId, foreignAccountId, foreignFlockId, itemId, Today, - 1m, "kg", Money.Zero("USD"), DateTime.UtcNow)); + 1m, "kg", Money.Zero("USD"))); db.WaterUsages.Add(WaterUsage.Create( foreignWaterId, foreignAccountId, foreignFlockId, Today, 1m, "L", WaterSource.Municipal, - meterStart: null, meterEnd: null, DateTime.UtcNow)); + meterStart: null, meterEnd: null)); db.Expenses.Add(Cluckwork.Domain.Expenses.Expense.Create( foreignExpenseId, foreignAccountId, foreignFarmId, categoryId, Today, "Foreign tenant guard expense", 100, "USD", 2, @@ -518,12 +518,12 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => lotAId, fix.AccountId, fix.FlockA, Today, fix.GradeId, 10)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( movementAId, fix.AccountId, lotAId, EggMovementType.Production, - 10, "ParentGate", Guid.NewGuid(), DateTimeOffset.UtcNow)); + 10, "ParentGate", Guid.NewGuid())); db.EggLots.Add(EggLot.Create( lotBId, fix.AccountId, fix.FlockB, Today, fix.GradeId, 10)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( movementBId, fix.AccountId, lotBId, EggMovementType.Production, - 10, "ParentGate", Guid.NewGuid(), DateTimeOffset.UtcNow)); + 10, "ParentGate", Guid.NewGuid())); await db.SaveChangesAsync(); }); @@ -565,13 +565,13 @@ await factory.WithTenantScopeAsync(fix.AccountId, async db => dailyEntryId: dailyAId)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( Guid.NewGuid(), fix.AccountId, lotAId, EggMovementType.Production, - 25, "DailyEntry", dailyAId, DateTimeOffset.UtcNow)); + 25, "DailyEntry", dailyAId)); db.EggLots.Add(EggLot.Create( lotBId, fix.AccountId, fix.FlockB, Today, fix.GradeId, 30, dailyEntryId: dailyBId)); db.EggInventoryMovements.Add(EggInventoryMovement.Create( Guid.NewGuid(), fix.AccountId, lotBId, EggMovementType.Production, - 30, "DailyEntry", dailyBId, DateTimeOffset.UtcNow)); + 30, "DailyEntry", dailyBId)); await db.SaveChangesAsync(); }); diff --git a/tests/Cluckwork.Api.IntegrationTests/Infrastructure/TestHarness.cs b/tests/Cluckwork.Api.IntegrationTests/Infrastructure/TestHarness.cs index df16f6ac2..4a3784149 100644 --- a/tests/Cluckwork.Api.IntegrationTests/Infrastructure/TestHarness.cs +++ b/tests/Cluckwork.Api.IntegrationTests/Infrastructure/TestHarness.cs @@ -289,7 +289,7 @@ await factory.WithTenantScopeAsync(accountId, async db => // the real submit path writes it. db.EggInventoryMovements.Add(Cluckwork.Domain.Eggs.EggInventoryMovement.Create( Guid.NewGuid(), accountId, lotId, Cluckwork.Domain.Eggs.EggMovementType.Production, - quantity, "DailyEntry", Guid.NewGuid(), DateTimeOffset.UtcNow)); + quantity, "DailyEntry", Guid.NewGuid())); db.EggLots.Add(lot); await db.SaveChangesAsync(); }); diff --git a/tests/Cluckwork.Api.IntegrationTests/ListChronologyTests.cs b/tests/Cluckwork.Api.IntegrationTests/ListChronologyTests.cs new file mode 100644 index 000000000..e5d2c74cb --- /dev/null +++ b/tests/Cluckwork.Api.IntegrationTests/ListChronologyTests.cs @@ -0,0 +1,177 @@ +namespace Cluckwork.Api.IntegrationTests; + +using Cluckwork.Api.IntegrationTests.Infrastructure; +using Cluckwork.Application.Features.Export; +using Cluckwork.Application.Features.Sales; +using Cluckwork.Domain.Common; +using Cluckwork.Domain.Eggs; +using Cluckwork.Domain.Expenses; +using Cluckwork.Domain.Flocks; +using Cluckwork.Domain.Inventory; +using Cluckwork.Domain.Sales; +using Cluckwork.Infrastructure.Persistence; +using Cluckwork.Infrastructure.Repositories; + +// #819 — these lists lead with a business date and row-creation time. Sequence +// completes the insertion order without relying on a random v4 Guid. +[Collection(IntegrationCollection.Name)] +public sealed class ListChronologyTests(CluckworkWebApplicationFactory factory) +{ + // Deliberately oppose Guid-descending order: the earlier row has the larger + // id, so every pre-#819 query returns it first and this test goes red. + private static readonly Guid EarlierId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"); + private static readonly Guid LaterId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private static readonly DateOnly Today = new(2026, 9, 13); + + [Fact] + public async Task SameDayLists_ReturnTheLaterInsertedRowFirst() + { + var accountId = await factory.SeedAccountWithUserAsync( + $"chronology-{Guid.NewGuid():N}@test.local"); + + await factory.WithTenantScopeAsync(accountId, async db => + { + var farmId = Guid.NewGuid(); + var flockId = Guid.NewGuid(); + var customer = Customer.Create(Guid.NewGuid(), accountId, "Chronology customer", "1"); + var category = ExpenseCategory.Create( + Guid.NewGuid(), accountId, farmId, "Chronology category"); + var grade = EggGrade.Create( + Guid.NewGuid(), accountId, farmId, "Chronology grade", + EggGradeType.Size, 0, isSaleable: true); + var flock = Flock.Create( + flockId, accountId, farmId, Guid.NewGuid(), "Chronology flock", + "Test breed", Today.AddDays(-30), 100); + var item = InventoryItem.Create( + Guid.NewGuid(), accountId, farmId, "Chronology feed", + InventoryCategory.Feed, "kg", Money.Zero("USD")); + db.AddRange(customer, category, grade, flock, item); + await db.SaveChangesAsync(); + + await InsertSeparatelyAsync( + SalesOrder.Create(EarlierId, accountId, customer.Id, "SO-EARLIER", Today, "USD"), + SalesOrder.Create(LaterId, accountId, customer.Id, "SO-LATER", Today, "USD")); + await InsertSeparatelyAsync( + BirdMovement.Create(EarlierId, accountId, flockId, Today, BirdMovementType.Cull, 1), + BirdMovement.Create(LaterId, accountId, flockId, Today, BirdMovementType.Cull, 2)); + await InsertSeparatelyAsync( + Expense.Create(EarlierId, accountId, farmId, category.Id, Today, + "Earlier expense", 100, "USD", 2), + Expense.Create(LaterId, accountId, farmId, category.Id, Today, + "Later expense", 200, "USD", 2)); + await InsertSeparatelyAsync( + EggLot.Create(EarlierId, accountId, flockId, Today, grade.Id, 10), + EggLot.Create(LaterId, accountId, flockId, Today, grade.Id, 20)); + await InsertSeparatelyAsync( + DailyEntry.Create(EarlierId, accountId, farmId, Guid.NewGuid(), flockId, Today), + DailyEntry.Create(LaterId, accountId, farmId, Guid.NewGuid(), flockId, Today)); + await InsertSeparatelyAsync( + Payment.Create(EarlierId, accountId, EarlierId, customer.Id, Today, + 100, "USD", 2, PaymentMethod.Cash), + Payment.Create(LaterId, accountId, EarlierId, customer.Id, Today, + 200, "USD", 2, PaymentMethod.Cash)); + await InsertSeparatelyAsync( + InventoryLot.Create(EarlierId, accountId, item.Id, Today, 10, + Money.Zero("USD"), null, null), + InventoryLot.Create(LaterId, accountId, item.Id, Today, 20, + Money.Zero("USD"), null, null)); + await InsertSeparatelyAsync( + FeedUsage.Create(EarlierId, accountId, flockId, item.Id, Today, 1, + "kg", Money.Zero("USD")), + FeedUsage.Create(LaterId, accountId, flockId, item.Id, Today, 2, + "kg", Money.Zero("USD"))); + await InsertSeparatelyAsync( + WaterUsage.Create(EarlierId, accountId, flockId, Today, 1, + "L", WaterSource.Well, null, null), + WaterUsage.Create(LaterId, accountId, flockId, Today, 2, + "L", WaterSource.Well, null, null)); + + var earlierInventoryMovement = InventoryMovement.Create( + accountId, item.Id, null, Today, InventoryMovementType.Adjustment, 1, "kg"); + var laterInventoryMovement = InventoryMovement.Create( + accountId, item.Id, null, Today, InventoryMovementType.Adjustment, 2, "kg"); + await InsertSeparatelyAsync(earlierInventoryMovement, laterInventoryMovement); + + var earlierEggMovement = EggInventoryMovement.Create( + EarlierId, accountId, EarlierId, EggMovementType.Production, 1, + nameof(DailyEntry), EarlierId); + var laterEggMovement = EggInventoryMovement.Create( + LaterId, accountId, EarlierId, EggMovementType.Production, 2, + nameof(DailyEntry), LaterId); + await InsertSeparatelyAsync(earlierEggMovement, laterEggMovement); + + var orderFilter = new SalesOrderListFilter(null, null, null, null, SettlementScope.Hidden); + var hiddenOrders = await new SalesOrderRepository(db).ListAsync(orderFilter, 10, 0); + AssertLaterFirst(hiddenOrders.Select(x => x.Order.Id)); + + orderFilter = orderFilter with { Settlement = SettlementScope.Visible }; + var visibleOrders = await new SalesOrderRepository(db).ListAsync(orderFilter, 10, 0); + AssertLaterFirst(visibleOrders.Select(x => x.Order.Id)); + + AssertLaterFirst((await new BirdMovementRepository(db) + .ListByFlockAsync(flockId, 10, 0)).Select(x => x.Id)); + AssertLaterFirst((await new ExpenseRepository(db) + .ListAsync(null, null, null, 10, 0)).Select(x => x.Id)); + AssertLaterFirst((await new EggLotRepository(db) + .ListAsync(null, null, null, 10, 0)).Select(x => x.Id)); + AssertLaterFirst((await new DailyEntryRepository(db) + .ListAsync(null, null, null, 10, 0)).Select(x => x.Id)); + Assert.Equal( + [EarlierId, LaterId], + (await new PaymentRepository(db) + .ListByOrderAsync(EarlierId)).Select(x => x.Id).ToArray()); + AssertLaterFirst((await new InventoryLotRepository(db) + .ListByItemAsync(item.Id)).Select(x => x.Id)); + AssertLaterFirst((await new FeedUsageRepository(db) + .ListAsync(null, null, null, 10, 0)).Select(x => x.Id)); + AssertLaterFirst((await new WaterUsageRepository(db) + .ListAsync(null, null, null, 10, 0)).Select(x => x.Id)); + Assert.Equal( + [laterInventoryMovement.Id, earlierInventoryMovement.Id], + (await new InventoryMovementRepository(db) + .ListByItemAsync(item.Id, 10, 0)).Select(x => x.Id).ToArray()); + AssertLaterFirst((await new EggInventoryMovementRepository(db) + .ListByLotAsync(EarlierId)).Select(x => x.Id)); + + var exports = new ExportQueries(db, new TenantContext(), new FlockScope()); + await AssertExportOrderAsync("sales-orders", EarlierId, LaterId); + await AssertExportOrderAsync("bird-movements", EarlierId, LaterId); + await AssertExportOrderAsync("expenses", EarlierId, LaterId); + await AssertExportOrderAsync("egg-lots", EarlierId, LaterId); + await AssertExportOrderAsync("daily-entries", EarlierId, LaterId); + await AssertExportOrderAsync("payments", EarlierId, LaterId); + await AssertExportOrderAsync("inventory-lots", EarlierId, LaterId); + await AssertExportOrderAsync("feed-usages", EarlierId, LaterId); + await AssertExportOrderAsync("water-usages", EarlierId, LaterId); + await AssertExportOrderAsync( + "inventory-movements", earlierInventoryMovement.Id, laterInventoryMovement.Id); + await AssertExportOrderAsync("egg-inventory-movements", EarlierId, LaterId); + + async Task InsertSeparatelyAsync(TEntity earlier, TEntity later) + where TEntity : class + { + db.Add(earlier); + await db.SaveChangesAsync(); + db.Add(later); + await db.SaveChangesAsync(); + } + + async Task AssertExportOrderAsync(string datasetName, Guid earlier, Guid later) + { + var dataset = Assert.IsType(exports.GetDataset(datasetName)); + var ids = new List(); + await foreach (var row in dataset.Rows) + { + var id = Assert.IsType(row[0]); + if (id == earlier || id == later) + ids.Add(id); + } + + Assert.Equal([earlier, later], ids); + } + }); + } + + private static void AssertLaterFirst(IEnumerable ids) => + Assert.Equal([LaterId, EarlierId], ids.ToArray()); +} diff --git a/tests/Cluckwork.Api.IntegrationTests/NamedRowProjectionTests.cs b/tests/Cluckwork.Api.IntegrationTests/NamedRowProjectionTests.cs index a93002f82..e6068f02d 100644 --- a/tests/Cluckwork.Api.IntegrationTests/NamedRowProjectionTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/NamedRowProjectionTests.cs @@ -264,17 +264,17 @@ await factory.WithTenantScopeAsync(accountId, async db => db.WaterUsages.Add(WaterUsage.Create( f.WaterActive, accountId, f.ActiveFlock, From, 10m, "L", - WaterSource.Well, null, null, DateTime.UtcNow)); + WaterSource.Well, null, null)); db.WaterUsages.Add(WaterUsage.Create( f.WaterArchived, accountId, f.ArchivedFlock, From.AddMonths(1), 11m, "L", - WaterSource.Well, null, null, DateTime.UtcNow)); + WaterSource.Well, null, null)); db.FeedUsages.Add(FeedUsage.Create( f.FeedActive, accountId, f.ActiveFlock, f.ItemId, From, 5m, "kg", - Money.Zero("USD"), DateTime.UtcNow)); + Money.Zero("USD"))); db.FeedUsages.Add(FeedUsage.Create( f.FeedArchived, accountId, f.ArchivedFlock, f.ItemId, From.AddMonths(1), 6m, "kg", - Money.Zero("USD"), DateTime.UtcNow)); + Money.Zero("USD"))); db.SalesOrders.Add(SalesOrder.Create( f.OrderA, accountId, f.CustomerA, $"{f.Marker}-A", From, "USD")); diff --git a/tests/Cluckwork.Api.IntegrationTests/SaleAllocationPolicyTests.cs b/tests/Cluckwork.Api.IntegrationTests/SaleAllocationPolicyTests.cs index 7bd80da43..faad14dbb 100644 --- a/tests/Cluckwork.Api.IntegrationTests/SaleAllocationPolicyTests.cs +++ b/tests/Cluckwork.Api.IntegrationTests/SaleAllocationPolicyTests.cs @@ -55,7 +55,7 @@ await factory.WithTenantScopeAsync(accountId, async db => var lot = EggLot.Create(lotId, accountId, flockId, productionDate ?? Today, gradeId, quantity); db.EggInventoryMovements.Add(EggInventoryMovement.Create( Guid.NewGuid(), accountId, lotId, EggMovementType.Production, - quantity, "DailyEntry", Guid.NewGuid(), DateTimeOffset.UtcNow)); + quantity, "DailyEntry", Guid.NewGuid())); db.EggLots.Add(lot); await db.SaveChangesAsync(); }); diff --git a/tests/Cluckwork.Domain.Tests/Inventory/InventoryTests.cs b/tests/Cluckwork.Domain.Tests/Inventory/InventoryTests.cs index f28150a53..bba714a0a 100644 --- a/tests/Cluckwork.Domain.Tests/Inventory/InventoryTests.cs +++ b/tests/Cluckwork.Domain.Tests/Inventory/InventoryTests.cs @@ -158,17 +158,17 @@ public void FeedUsage_Create_Guards() { var usage = FeedUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), - Today, 12.5m, "kg", new Money(30000, "USD", 2), Now, " morning feed "); + Today, 12.5m, "kg", new Money(30000, "USD", 2), " morning feed "); Assert.Equal(12.5m, usage.Quantity); Assert.Equal("morning feed", usage.Note); - Assert.Equal(Now, usage.CreatedAtUtc); + Assert.Equal(default, usage.CreatedAtUtc); Assert.Throws(() => FeedUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), - Today, 0m, "kg", Money.Zero("USD"), Now)); + Today, 0m, "kg", Money.Zero("USD"))); Assert.Throws(() => FeedUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), - Today, 1m, "kg", new Money(-1, "USD", 2), Now)); + Today, 1m, "kg", new Money(-1, "USD", 2))); } [Fact] @@ -176,10 +176,10 @@ public void Movement_ReferenceTypeAndId_MustBeSetTogether() { Assert.Throws(() => InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Usage, -1m, "kg", Now, referenceType: "FeedUsage")); + InventoryMovementType.Usage, -1m, "kg", referenceType: "FeedUsage")); Assert.Throws(() => InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Usage, -1m, "kg", Now, referenceId: Guid.NewGuid())); + InventoryMovementType.Usage, -1m, "kg", referenceId: Guid.NewGuid())); } // --- WaterUsage --- @@ -189,7 +189,7 @@ public void WaterUsage_Create_DirectQuantity() { var usage = WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 120.5m, "L", WaterSource.Well, null, null, Now, " tank refill "); + 120.5m, "L", WaterSource.Well, null, null, " tank refill "); Assert.Equal(120.5m, usage.Quantity); Assert.Equal("tank refill", usage.Note); Assert.Null(usage.MeterStart); @@ -201,23 +201,23 @@ public void WaterUsage_MeterRules() // Meters must travel together. Assert.Throws(() => WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 10m, "L", WaterSource.Well, 100m, null, Now)); + 10m, "L", WaterSource.Well, 100m, null)); // End before start refused; equal readings too (zero delta would // otherwise surface as a confusing quantity error). Assert.Throws(() => WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 10m, "L", WaterSource.Well, 100m, 90m, Now)); + 10m, "L", WaterSource.Well, 100m, 90m)); Assert.Throws(() => WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 10m, "L", WaterSource.Well, 100m, 100m, Now)); + 10m, "L", WaterSource.Well, 100m, 100m)); // Quantity must equal the delta. Assert.Throws(() => WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 15m, "L", WaterSource.Well, 100m, 110m, Now)); + 15m, "L", WaterSource.Well, 100m, 110m)); // Consistent meters accepted. var ok = WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 10m, "L", WaterSource.Municipal, 100m, 110m, Now); + 10m, "L", WaterSource.Municipal, 100m, 110m); Assert.Equal(10m, ok.Quantity); } @@ -226,7 +226,7 @@ public void WaterUsage_Update_BumpsVersion_AndKeepsFlockDate() { var usage = WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 50m, "L", WaterSource.Well, null, null, Now); + 50m, "L", WaterSource.Well, null, null); var before = usage.Version; var result = usage.Update(60m, "gal", WaterSource.Tank, null, null, "recount"); @@ -242,25 +242,23 @@ public void WaterUsage_InvalidUnit_Throws() { Assert.Throws(() => WaterUsage.Create( Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Today, - 10m, "m3", WaterSource.Well, null, null, Now)); + 10m, "m3", WaterSource.Well, null, null)); } // --- InventoryMovement --- - private static readonly DateTime Now = new(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc); - [Fact] public void Movement_PurchaseMustBePositive_UsageMustBeNegative() { Assert.Throws(() => InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Purchase, -5m, "kg", Now)); + InventoryMovementType.Purchase, -5m, "kg")); Assert.Throws(() => InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Usage, 5m, "kg", Now)); + InventoryMovementType.Usage, 5m, "kg")); Assert.Throws(() => InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Adjustment, 0m, "kg", Now)); + InventoryMovementType.Adjustment, 0m, "kg")); } [Fact] @@ -268,9 +266,9 @@ public void Movement_AdjustmentMayBeNegative() { var movement = InventoryMovement.Create( Guid.NewGuid(), Guid.NewGuid(), null, Today, - InventoryMovementType.Adjustment, -2.5m, "kg", Now, note: "spillage correction"); + InventoryMovementType.Adjustment, -2.5m, "kg", note: "spillage correction"); Assert.Equal(-2.5m, movement.QuantityDelta); Assert.Equal("spillage correction", movement.Note); - Assert.Equal(Now, movement.CreatedAtUtc); + Assert.Equal(default, movement.CreatedAtUtc); } }