feat: T4b wave 7 - schema for phenology, honey movement ledger, shelf life

- Phenology reports, apiary phenology state and weather days with
  permissions for system roles
- Honey movement ledger: BOTTLING movements and initialAmount backfilled
  from existing bottlings with the same gram rounding; the migration
  reports counts and aborts if the ledger invariant fails
- Shelf life and event catalog columns; backup export/import of the new
  tables, old bundles keep importing, invariant check after imports
- e2e: test timeout 20 s (app start in hooks exceeded 5 s under load)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 19:19:32 +02:00
1 parent 326b3d9cdd
commit 22382d53a2
17 files changed
+1213 -39

No files matched your search

@@ -0,0 +1,105 @@
-- T4 migration 4/n: plant development (phenology) and the season segment.
-- PhenologyReport holds the beekeeper's own reports; ApiaryPhenology and
-- ApiaryWeatherDay are derived/cached state written only by the
-- daily-evaluation step and the weather loop.
-- CreateEnum
CREATE TYPE "PhenologyPhase" AS ENUM ('SALWEIDE', 'KIRSCHE', 'APFEL', 'HOLUNDER', 'LINDE', 'TRACHTENDE', 'HERBST', 'WINTER');
-- CreateEnum
CREATE TYPE "SeasonSegment" AS ENUM ('WINTERRUHE', 'FRUEHJAHR', 'SCHWARMZEIT', 'SOMMER', 'SPAETSOMMER', 'HERBST');
-- CreateEnum
CREATE TYPE "PhaseSource" AS ENUM ('REPORT', 'GTS', 'CALENDAR');
-- CreateTable
CREATE TABLE "PhenologyReport" (
"id" TEXT NOT NULL,
"apiaryId" TEXT,
"phase" "PhenologyPhase" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"userId" TEXT NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PhenologyReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryPhenology" (
"apiaryId" TEXT NOT NULL,
"year" INTEGER NOT NULL,
"gts" DOUBLE PRECISION,
"gtsThroughDay" TIMESTAMP(3),
"missingDays" INTEGER NOT NULL DEFAULT 0,
"estimatedPhase" "PhenologyPhase",
"reportedPhase" "PhenologyPhase",
"effectivePhase" "PhenologyPhase",
"phaseSource" "PhaseSource",
"segment" "SeasonSegment" NOT NULL,
"segmentSince" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryPhenology_pkey" PRIMARY KEY ("apiaryId","year")
);
-- CreateTable
CREATE TABLE "ApiaryWeatherDay" (
"apiaryId" TEXT NOT NULL,
"day" TIMESTAMP(3) NOT NULL,
"meanTempC" DOUBLE PRECISION,
"source" TEXT NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiaryWeatherDay_pkey" PRIMARY KEY ("apiaryId","day")
);
-- CreateIndex
CREATE INDEX "PhenologyReport_date_idx" ON "PhenologyReport"("date");
-- CreateIndex
CREATE INDEX "PhenologyReport_apiaryId_phase_date_idx" ON "PhenologyReport"("apiaryId", "phase", "date");
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PhenologyReport" ADD CONSTRAINT "PhenologyReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryPhenology" ADD CONSTRAINT "ApiaryPhenology_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryWeatherDay" ADD CONSTRAINT "ApiaryWeatherDay_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Permissions: the boot-time catalog sync grants new permissions to system
-- roles only for rows it creates itself, and these rows exist before the
-- first boot. So system roles (ADMIN: full access) get all three here; this
-- mirrors what PermissionCatalogSyncService does for permissions it creates.
-- Phenology reports are everyday beekeeping records, so non-system roles get
-- them like winter-checks (assumption 16): read with colonies:read,
-- create/update with inspections:create.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'phenology:read', 'phenology', 'read', 'Ansehen', 'Pflanzenentwicklung', 860),
(gen_random_uuid()::text, 'phenology:create', 'phenology', 'create', 'Melden', 'Pflanzenentwicklung', 861),
(gen_random_uuid()::text, 'phenology:update', 'phenology', 'update', 'Bearbeiten', 'Pflanzenentwicklung', 862)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r."isSystem" = true AND p.resource = 'phenology'
ON CONFLICT DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT DISTINCT rp."roleId", p.id, now()
FROM "RolePermission" rp
JOIN "Role" r ON r.id = rp."roleId" AND r."isSystem" = false
JOIN "Permission" have ON have.id = rp."permissionId"
JOIN "Permission" p ON p.resource = 'phenology'
WHERE (have.key = 'colonies:read' AND p.action = 'read')
OR (have.key = 'inspections:create' AND p.action IN ('create', 'update'))
ON CONFLICT DO NOTHING;
@@ -0,0 +1,84 @@
-- T4 migration 6/n: ledger of the honey batch. HoneyBatch.amount (kg) stays
-- the running rest; initialAmount (kg) is the starting quantity, and
-- amount = initialAmount + sum(BatchMovement.quantity) / 1000
-- (quantity in grams, signed). Every bottling is booked as a BOTTLING
-- movement. Earlier hand corrections of amount are folded into initialAmount.
--
-- Invariant check afterwards (must return 0 rows):
-- SELECT hb."id" FROM "HoneyBatch" hb
-- LEFT JOIN (SELECT "honeyBatchId", sum("quantity") g FROM "BatchMovement"
-- WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
-- WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
-- AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
-- AlterTable
ALTER TABLE "BatchMovement" ADD COLUMN "honeyBatchId" TEXT,
ADD COLUMN "sourceBottlingBatchId" TEXT;
-- AlterTable
ALTER TABLE "HoneyBatch" ADD COLUMN "initialAmount" DOUBLE PRECISION;
-- CreateIndex
CREATE INDEX "BatchMovement_honeyBatchId_idx" ON "BatchMovement"("honeyBatchId");
-- CreateIndex
CREATE INDEX "BatchMovement_sourceBottlingBatchId_idx" ON "BatchMovement"("sourceBottlingBatchId");
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_sourceBottlingBatchId_fkey" FOREIGN KEY ("sourceBottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- A movement now belongs to exactly one of four batch kinds (name kept from
-- 20261005100000_raw_materials_production).
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId", "honeyBatchId") = 1);
-- Recalculation, only for batches with a recorded amount (a batch without
-- one is not booked, as before).
-- 1. initialAmount = amount + the bottlings rounded to the gram exactly like
-- the bookings below (kg), so the invariant holds without a rounding gap.
UPDATE "HoneyBatch" hb
SET "initialAmount" = hb."amount" + coalesce((
SELECT sum(floor(b."totalAmount" * 1000 + 0.5)) FROM "BottlingBatch" b WHERE b."honeyBatchId" = hb."id"
), 0) / 1000.0
WHERE hb."amount" IS NOT NULL;
-- 2. One BOTTLING movement per bottling, in grams, dated like the bottling.
-- floor(x + 0.5) rounds half up like the application (Math.round), whereas
-- round(double precision) may round half to even.
INSERT INTO "BatchMovement" ("id", "honeyBatchId", "sourceBottlingBatchId", "type", "quantity", "notes", "userId", "createdAt")
SELECT gen_random_uuid()::text, b."honeyBatchId", b."id", 'BOTTLING',
-(floor(b."totalAmount" * 1000 + 0.5))::integer,
'Übernahme aus Abfüllung (T4)', b."userId", b."createdAt"
FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId"
WHERE hb."amount" IS NOT NULL;
-- Report and self-check: the counts go to the migration log; a broken
-- invariant aborts the whole migration (the file runs in one transaction).
DO $$
DECLARE
batches integer;
bookings integer;
skipped integer;
broken integer;
BEGIN
SELECT count(*) INTO batches FROM "HoneyBatch" WHERE "initialAmount" IS NOT NULL;
SELECT count(*) INTO bookings FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL AND "type" = 'BOTTLING';
SELECT count(*) INTO skipped FROM "BottlingBatch" b
JOIN "HoneyBatch" hb ON hb."id" = b."honeyBatchId" WHERE hb."amount" IS NULL;
RAISE NOTICE 'honey ledger: % batches with initialAmount, % bookings created, % bottlings skipped (batch amount is NULL)',
batches, bookings, skipped;
SELECT count(*) INTO broken FROM "HoneyBatch" hb
LEFT JOIN (SELECT "honeyBatchId", sum("quantity") AS g FROM "BatchMovement"
WHERE "honeyBatchId" IS NOT NULL GROUP BY 1) m ON m."honeyBatchId" = hb."id"
WHERE hb."initialAmount" IS NOT NULL AND hb."amount" IS NOT NULL
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.g, 0) / 1000.0)) > 0.0005;
IF broken > 0 THEN
RAISE EXCEPTION 'honey ledger invariant broken for % batches', broken;
END IF;
END $$;
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part shelf life): Product.shelfLifeMonths, the shelf life
-- in months, used to pre-fill the best-before date. No default value.
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "shelfLifeMonths" INTEGER;
+136 -35
View File
@@ -111,6 +111,7 @@ model User {
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
phenologyReports PhenologyReport[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
@@ -240,11 +241,14 @@ model Apiary {
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
treatments Treatment[]
rawMaterialLots RawMaterialLot[]
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
@@index([status])
}
@@ -928,6 +932,9 @@ model HoneyBatch {
extraction Extraction @relation(fields: [extractionId], references: [id])
honeyType String?
amount Float?
// Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000.
// null = Menge nicht erfasst, dann wird nicht gebucht.
initialAmount Float?
waterContent Float?
storageContainerId String?
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
@@ -939,6 +946,7 @@ model HoneyBatch {
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[]
movements BatchMovement[]
@@index([status])
@@index([extractionId])
@@ -1105,18 +1113,20 @@ enum ShopCategory {
}
model Product {
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
// Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
shelfLifeMonths Int?
status ProductStatus @default(ACTIVE)
kind ProductKind @default(BOTTLED_HONEY)
shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
@@ -1174,7 +1184,8 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
movements BatchMovement[]
movements BatchMovement[]
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
@@index([honeyBatchId])
@@index([productId])
@@ -1288,28 +1299,36 @@ enum WithdrawalReason {
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
// Migration).
model BatchMovement {
id String @id @default(cuid())
bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType
id String @id @default(cuid())
bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
// Honig: Bewegungsbuch der Honigcharge, quantity dann in Gramm.
honeyBatchId String?
honeyBatch HoneyBatch? @relation(fields: [honeyBatchId], references: [id], onDelete: Restrict)
// Rueckverweis auf die verursachende Abfuellung (BOTTLING).
sourceBottlingBatchId String?
sourceBottlingBatch BottlingBatch? @relation("BatchMovementSourceBottling", fields: [sourceBottlingBatchId], references: [id], onDelete: SetNull)
type BatchMovementType
// Mit Vorzeichen: minus = raus, plus = rein.
quantity Int
reason WithdrawalReason?
notes String?
shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String?
user User? @relation(fields: [userId], references: [id])
apiKeyId String?
createdAt DateTime @default(now())
quantity Int
reason WithdrawalReason?
notes String?
shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String?
user User? @relation(fields: [userId], references: [id])
apiKeyId String?
createdAt DateTime @default(now())
@@index([bottlingBatchId])
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([honeyBatchId])
@@index([sourceBottlingBatchId])
@@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
@@ -1789,3 +1808,85 @@ model ThrottlerHit {
@@id([key, throttlerName])
@@index([expiresAt])
}
// ---------------------------------------------------------------------------
// Pflanzenentwicklung und Saisonabschnitt (T4b, Spec 4.2)
// ---------------------------------------------------------------------------
enum PhenologyPhase {
SALWEIDE
KIRSCHE
APFEL
HOLUNDER
LINDE
TRACHTENDE
HERBST
WINTER
}
enum SeasonSegment {
WINTERRUHE
FRUEHJAHR
SCHWARMZEIT
SOMMER
SPAETSOMMER
HERBST
}
enum PhaseSource {
REPORT
GTS
CALENDAR
}
// Eigene Meldung per Antippen; zurueckgezogen statt geloescht.
model PhenologyReport {
id String @id @default(cuid())
// null = alle Staende
apiaryId String?
apiary Apiary? @relation(fields: [apiaryId], references: [id], onDelete: Restrict)
phase PhenologyPhase
date DateTime
notes String?
userId String
user User @relation(fields: [userId], references: [id])
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([date])
@@index([apiaryId, phase, date])
}
// Abgeleiteter Zustand, schreibt nur der Job/Handler (nicht im Backup).
model ApiaryPhenology {
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
year Int
gts Float?
gtsThroughDay DateTime?
missingDays Int @default(0)
estimatedPhase PhenologyPhase?
reportedPhase PhenologyPhase?
effectivePhase PhenologyPhase?
phaseSource PhaseSource?
segment SeasonSegment
segmentSince DateTime
updatedAt DateTime @updatedAt
@@id([apiaryId, year])
}
// Tagesmittel je Stand, schreibt nur die Worker-Schleife (nicht im Backup).
model ApiaryWeatherDay {
apiaryId String
apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade)
// Kalendertag Europe/Berlin
day DateTime
meanTempC Float?
// 'forecast' | 'archive'
source String
fetchedAt DateTime @default(now())
@@id([apiaryId, day])
}