From 22382d53a2ce0b2c42a25c6d0fcf2a63ff323d9c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 14 Oct 2026 19:19:32 +0200 Subject: [PATCH] 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) --- .../20261015100000_t4_phenology/migration.sql | 105 +++++ .../migration.sql | 84 ++++ .../migration.sql | 5 + ApiCore-Backend/prisma/schema.prisma | 171 ++++++-- .../auth/permissions/permission-catalog.ts | 12 + .../src/backup/backup.service.spec.ts | 167 ++++++++ ApiCore-Backend/src/backup/backup.service.ts | 88 +++- .../src/backup/legacy-honey-ledger.spec.ts | 57 +++ .../src/backup/legacy-honey-ledger.ts | 74 ++++ .../src/domain-events/domain-event-types.ts | 42 +- .../domain-events.service.spec.ts | 9 +- ApiCore-Backend/src/prisma/row-lock.spec.ts | 4 + ApiCore-Backend/src/prisma/row-lock.ts | 6 + .../src/season/season-enums.spec.ts | 18 + .../test/honey-ledger-migration.e2e-spec.ts | 390 ++++++++++++++++++ ApiCore-Backend/test/jest-e2e.json | 1 + ApiCore-Backend/test/utils/honey-ledger.ts | 19 + 17 files changed, 1213 insertions(+), 39 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261015100000_t4_phenology/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261015100100_t4_honey_ledger/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261015100200_t4_product_shelf_life/migration.sql create mode 100644 ApiCore-Backend/src/backup/legacy-honey-ledger.spec.ts create mode 100644 ApiCore-Backend/src/backup/legacy-honey-ledger.ts create mode 100644 ApiCore-Backend/src/season/season-enums.spec.ts create mode 100644 ApiCore-Backend/test/honey-ledger-migration.e2e-spec.ts create mode 100644 ApiCore-Backend/test/utils/honey-ledger.ts diff --git a/ApiCore-Backend/prisma/migrations/20261015100000_t4_phenology/migration.sql b/ApiCore-Backend/prisma/migrations/20261015100000_t4_phenology/migration.sql new file mode 100644 index 0000000..23fd2b8 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261015100000_t4_phenology/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/migrations/20261015100100_t4_honey_ledger/migration.sql b/ApiCore-Backend/prisma/migrations/20261015100100_t4_honey_ledger/migration.sql new file mode 100644 index 0000000..6533235 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261015100100_t4_honey_ledger/migration.sql @@ -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 $$; diff --git a/ApiCore-Backend/prisma/migrations/20261015100200_t4_product_shelf_life/migration.sql b/ApiCore-Backend/prisma/migrations/20261015100200_t4_product_shelf_life/migration.sql new file mode 100644 index 0000000..172a958 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261015100200_t4_product_shelf_life/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index e5eb789..221ec39 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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]) +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 6c64ac0..ac83e4c 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [ ], 850, ), + // Seeded with role grants by the t4_phenology migration (read with + // colonies:read, create/update with inspections:create). + ...group( + 'Pflanzenentwicklung', + 'phenology', + [ + ['read', 'Ansehen'], + ['create', 'Melden'], + ['update', 'Bearbeiten'], + ], + 860, + ), ...group( 'Behandlungen', 'treatments', diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index dccc092..0fba831 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -34,6 +34,7 @@ const BUSINESS_ENTITY_KEYS = [ 'varroaMeasurement', 'swarmControlCheck', 'winterCheck', + 'phenologyReport', 'treatment', 'feeding', 'harvest', @@ -118,6 +119,9 @@ describe('BackupService', () => { upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }), findUnique: jest.fn().mockResolvedValue(null), } as never; + (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest + .fn() + .mockResolvedValue([]); tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.document.findUnique = jest.fn().mockResolvedValue(null); @@ -1451,6 +1455,169 @@ describe('BackupService', () => { }); }); + describe('phenology and honey ledger', () => { + const importData = (data: Record, trusted = true) => + service.importAll( + { version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data }, + 'admin-1', + { trusted }, + ); + const created = (key: string): Row[] => + (tx[key].upsert.mock.calls as [{ create: Row }][]).map( + ([arg]) => arg.create, + ); + + it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => { + prisma.phenologyReport.findMany.mockResolvedValue([ + { id: 'p1', phase: 'KIRSCHE', userId: 'known-user' }, + ]); + + const bundle = await service.exportAll(); + + expect(bundle.data.phenologyReport).toHaveLength(1); + expect(bundle.data).not.toHaveProperty('apiaryPhenology'); + expect(bundle.data).not.toHaveProperty('apiaryWeatherDay'); + expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch( + /recomputed by daily-evaluation/, + ); + expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch( + /refetched by the worker/, + ); + expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport'); + }); + + it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => { + await importData({ + apiary: [{ id: 'apiary-1' }], + phenologyReport: [ + { id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' }, + { id: 'p2', apiaryId: null, userId: 'ghost' }, + ], + }); + + expect(created('phenologyReport').map((row) => row.userId)).toEqual([ + 'known-user', + 'admin-1', + ]); + expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan( + tx.phenologyReport.upsert.mock.invocationCallOrder[0], + ); + }); + + it('an old bundle without initialAmount is recalculated like migration 6', async () => { + await importData({ + honeyBatch: [ + { id: 'hb-1', amount: 20 }, + { id: 'hb-2', amount: null }, + { id: 'hb-3', amount: 5 }, + ], + bottlingBatch: [ + { + id: 'bt-1', + honeyBatchId: 'hb-1', + totalAmount: 7.5, + createdAt: '2026-08-01T10:00:00.000Z', + }, + { + id: 'bt-2', + honeyBatchId: 'hb-1', + totalAmount: 2.0004, + createdAt: '2026-08-02T10:00:00.000Z', + }, + { id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 }, + ], + }); + + expect( + created('honeyBatch').map((row) => [row.id, row.initialAmount]), + ).toEqual([ + ['hb-1', 29.5], + ['hb-2', undefined], + ['hb-3', 5], + ]); + expect(created('batchMovement')).toEqual([ + expect.objectContaining({ + honeyBatchId: 'hb-1', + sourceBottlingBatchId: 'bt-1', + type: 'BOTTLING', + quantity: -7500, + createdAt: '2026-08-01T10:00:00.000Z', + notes: 'Übernahme aus Abfüllung (T4)', + }), + expect.objectContaining({ + honeyBatchId: 'hb-1', + sourceBottlingBatchId: 'bt-2', + type: 'BOTTLING', + quantity: -2000, + }), + ]); + // After the batch and the bottling it points at. + expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan( + tx.batchMovement.upsert.mock.invocationCallOrder[0], + ); + }); + + it('leaves a bundle that carries initialAmount untouched', async () => { + await importData({ + honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }], + bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }], + batchMovement: [ + { + id: 'm1', + honeyBatchId: 'hb-1', + type: 'BOTTLING', + quantity: -10000, + }, + ], + }); + + expect(created('honeyBatch')[0].initialAmount).toBe(30); + expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']); + }); + + it('sets initialAmount from an unsealed file but books no movements', async () => { + await importData( + { + honeyBatch: [{ id: 'hb-new', amount: 20 }], + bottlingBatch: [ + { id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 }, + ], + }, + false, + ); + + expect(created('honeyBatch')[0].initialAmount).toBe(25); + expect(tx.batchMovement.upsert).not.toHaveBeenCalled(); + }); + + it('reports honey batches that break the invariant after the import, without failing it', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest + .fn() + .mockResolvedValue([{ id: 'hb-1' }]); + + const summary = await importData({ + honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }], + }); + + expect(summary.imported.honeyLedgerViolations).toBe(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1')); + warn.mockRestore(); + }); + + it('adds no violation count when the ledger is consistent', async () => { + (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest + .fn() + .mockResolvedValue([]); + + const summary = await importData({ + honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }], + }); + + expect(summary.imported).not.toHaveProperty('honeyLedgerViolations'); + }); + }); + // Drift guard: a model added to the schema has to be backed up or listed // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing. describe('model coverage', () => { diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 3ff2eb9..c9e0fd8 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -21,6 +21,7 @@ import { openBackup, sealBackup, } from './backup-crypto'; +import { legacyHoneyLedger } from './legacy-honey-ledger'; // 3: production, raw material, stock ledger, number sequence, calendar sync // and backup schedule tables. Older bundles simply lack those sections and @@ -71,6 +72,9 @@ export interface BackupImportSummary { * - siteSettings: keeper name/address printed on the Bestandsbuch and the * public links. * - document: writes files to the server's disk. + * - honeyBatch.initialAmount: an unsealed file may set it (derived from + * its bottlings) but never books ledger rows - batchMovement stays sealed + * only, so the BOTTLING bookings of an old bundle come with sealed files. * - notification, notificationPreference: messages shown to users as coming * from ApiCore, and settings of a user. */ @@ -140,6 +144,8 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly< 'Delivery log and retry queue - restoring it would resend old events to receivers.', JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.", ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.', + ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.', + ApiaryWeatherDay: 'Weather cache, refetched by the worker.', }; const DOCUMENT_ENTITY_TYPES = new Set( @@ -244,6 +250,32 @@ export class BackupService { }); } + /** Imported honey batches whose amount differs from the ledger by more than 0.5 g. */ + private async honeyLedgerViolations( + tx: Prisma.TransactionClient, + ids: string[], + ): Promise { + if (ids.length === 0) { + return []; + } + const rows = await tx.$queryRaw<{ id: string }[]>(Prisma.sql` + SELECT hb."id" + FROM "HoneyBatch" hb + LEFT JOIN ( + SELECT "honeyBatchId", sum("quantity") AS grams + FROM "BatchMovement" + WHERE "honeyBatchId" = ANY(${ids}) + GROUP BY "honeyBatchId" + ) m ON m."honeyBatchId" = hb."id" + WHERE hb."id" = ANY(${ids}) + AND hb."initialAmount" IS NOT NULL + AND hb."amount" IS NOT NULL + AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.grams, 0) / 1000.0)) > 0.0005 + ORDER BY hb."id" + `); + return rows.map((row) => row.id); + } + async exportAll(): Promise { const data: Record = {}; @@ -282,6 +314,7 @@ export class BackupService { data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.winterCheck = await this.prisma.winterCheck.findMany(); + data.phenologyReport = await this.prisma.phenologyReport.findMany(); data.treatment = await this.prisma.treatment.findMany(); data.feeding = await this.prisma.feeding.findMany(); data.harvest = await this.prisma.harvest.findMany(); @@ -459,6 +492,17 @@ export class BackupService { update: {}, }); } + + const violations = await this.honeyLedgerViolations( + tx, + rowsOf('honeyBatch').map((row) => row.id as string), + ); + if (violations.length > 0) { + imported.honeyLedgerViolations = violations.length; + this.logger.warn( + `Honigcharge(n) mit verletzter Mengen-Invariante nach dem Import: ${violations.join(', ')}`, + ); + } imported.rolePermission = rowsOf('rolePermission').length; // Built after role/user import so a user id that only exists @@ -709,6 +753,19 @@ export class BackupService { } imported.winterCheck = rowsOf('winterCheck').length; + for (const row of rowsOf('phenologyReport')) { + const report = { + ...row, + userId: resolveUserId(asString(row, 'userId')), + }; + await tx.phenologyReport.upsert({ + where: { id: row.id as string }, + create: report as never, + update: updateWith(report) as never, + }); + } + imported.phenologyReport = rowsOf('phenologyReport').length; + for (const row of rowsOf('treatment')) { await tx.treatment.upsert({ where: { id: row.id as string }, @@ -796,11 +853,25 @@ export class BackupService { } imported.storageContainer = rowsOf('storageContainer').length; + // Bundles from before the honey ledger: initialAmount is derived + // like migration 6 does. Only a sealed file also gets the BOTTLING + // bookings; an unsealed one may not write ledger rows. + const legacyLedger = legacyHoneyLedger( + rowsOf('honeyBatch'), + rowsOf('bottlingBatch'), + ); for (const row of rowsOf('honeyBatch')) { + const initialAmount = legacyLedger.initialAmounts.get( + row.id as string, + ); + const batch = + !('initialAmount' in row) && initialAmount !== undefined + ? { ...row, initialAmount } + : row; await tx.honeyBatch.upsert({ where: { id: row.id as string }, - create: row as never, - update: updateWith(row) as never, + create: batch as never, + update: updateWith(batch) as never, }); } imported.honeyBatch = rowsOf('honeyBatch').length; @@ -967,6 +1038,19 @@ export class BackupService { } imported.batchMovement = rowsOf('batchMovement').length; + // After the batches and bottlings they point at; never rewritten. + for (const row of trusted ? legacyLedger.movements : []) { + const movement = { + ...row, + userId: resolveOptionalUserId(asString(row, 'userId')), + }; + await tx.batchMovement.upsert({ + where: { id: row.id as string }, + create: movement as never, + update: {}, + }); + } + for (const row of rowsOf('equipment')) { await tx.equipment.upsert({ where: { id: row.id as string }, diff --git a/ApiCore-Backend/src/backup/legacy-honey-ledger.spec.ts b/ApiCore-Backend/src/backup/legacy-honey-ledger.spec.ts new file mode 100644 index 0000000..6de7912 --- /dev/null +++ b/ApiCore-Backend/src/backup/legacy-honey-ledger.spec.ts @@ -0,0 +1,57 @@ +import { + LEGACY_BOTTLING_NOTE, + legacyBottlingMovementId, + legacyHoneyLedger, +} from './legacy-honey-ledger'; + +describe('legacyHoneyLedger', () => { + it('derives initialAmount = amount + bottlings and one BOTTLING booking per bottling in grams', () => { + const ledger = legacyHoneyLedger( + [{ id: 'hb', amount: 20 }], + [ + { id: 'a', honeyBatchId: 'hb', totalAmount: 7.5, createdAt: 't1' }, + { id: 'b', honeyBatchId: 'hb', totalAmount: 2.5, createdAt: 't2' }, + ], + ); + + expect(ledger.initialAmounts.get('hb')).toBe(30); + expect(ledger.movements).toEqual([ + { + id: legacyBottlingMovementId('a'), + honeyBatchId: 'hb', + sourceBottlingBatchId: 'a', + type: 'BOTTLING', + quantity: -7500, + notes: LEGACY_BOTTLING_NOTE, + userId: null, + createdAt: 't1', + }, + expect.objectContaining({ quantity: -2500, createdAt: 't2' }), + ]); + }); + + it('ignores a batch with initialAmount (new format) and a batch without amount', () => { + const ledger = legacyHoneyLedger( + [ + { id: 'new', amount: 5, initialAmount: 8 }, + { id: 'none', amount: null }, + ], + [ + { id: 'a', honeyBatchId: 'new', totalAmount: 3 }, + { id: 'b', honeyBatchId: 'none', totalAmount: 3 }, + ], + ); + + expect(ledger.initialAmounts.size).toBe(0); + expect(ledger.movements).toEqual([]); + }); + + it('rounds half up to the gram like the migration', () => { + const ledger = legacyHoneyLedger( + [{ id: 'hb', amount: 1 }], + [{ id: 'a', honeyBatchId: 'hb', totalAmount: 0.0125 }], + ); + + expect(ledger.movements[0].quantity).toBe(-13); + }); +}); diff --git a/ApiCore-Backend/src/backup/legacy-honey-ledger.ts b/ApiCore-Backend/src/backup/legacy-honey-ledger.ts new file mode 100644 index 0000000..bed81aa --- /dev/null +++ b/ApiCore-Backend/src/backup/legacy-honey-ledger.ts @@ -0,0 +1,74 @@ +// Bundles written before the honey ledger (T4 migration 6) carry no +// HoneyBatch.initialAmount and no BOTTLING movements. The import derives +// both with the rule of the migration, so amount = initialAmount + sum of the +// movements holds for the restored batches too. + +type Row = Record; + +export const LEGACY_BOTTLING_NOTE = 'Übernahme aus Abfüllung (T4)'; + +export interface LegacyHoneyLedger { + /** initialAmount per honey batch id of a legacy batch with a recorded amount. */ + initialAmounts: ReadonlyMap; + /** The BOTTLING bookings of those batches, one per bottling. */ + movements: Row[]; +} + +function isLegacyBatch(row: Row): boolean { + return !('initialAmount' in row); +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +// Sums of kilograms carry float noise; a microgram grid hides it. +function roundKg(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + +/** Id of the booking made for a bottling; stable so a repeated restore adds nothing. */ +export function legacyBottlingMovementId(bottlingBatchId: string): string { + return `legacy-bottling:${bottlingBatchId}`; +} + +/** + * @param honeyBatches rows of the bundle's `honeyBatch` section + * @param bottlingBatches rows of the bundle's `bottlingBatch` section + */ +export function legacyHoneyLedger( + honeyBatches: readonly Row[], + bottlingBatches: readonly Row[], +): LegacyHoneyLedger { + const initialAmounts = new Map(); + for (const batch of honeyBatches) { + const amount = asNumber(batch.amount); + if (isLegacyBatch(batch) && amount !== null) { + initialAmounts.set(batch.id as string, amount); + } + } + + const movements: Row[] = []; + for (const bottling of bottlingBatches) { + const honeyBatchId = bottling.honeyBatchId as string; + const base = initialAmounts.get(honeyBatchId); + const total = asNumber(bottling.totalAmount); + if (base === undefined || total === null) { + continue; + } + // The same gram rounding as the booking, so the invariant holds exactly. + const grams = Math.round(total * 1000); + initialAmounts.set(honeyBatchId, roundKg(base + grams / 1000)); + movements.push({ + id: legacyBottlingMovementId(bottling.id as string), + honeyBatchId, + sourceBottlingBatchId: bottling.id, + type: 'BOTTLING', + quantity: -grams, + notes: LEGACY_BOTTLING_NOTE, + userId: bottling.userId ?? null, + createdAt: bottling.createdAt, + }); + } + return { initialAmounts, movements }; +} diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index 2c32d99..2210a44 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -1,8 +1,11 @@ import { ColonyCondition, ColonyStatus, + PhaseSource, + PhenologyPhase, Prisma, RawMaterialKind, + SeasonSegment, SplitQueenSource, TaskRelatedType, TaskStatus, @@ -89,6 +92,32 @@ export interface DomainEventPayloads { assignedOn: string; previousColonyId: string | null; }; + // Aggregate Extraction. + ExtractionRecorded: { harvestIds: string[] }; + ExtractionCorrected: { harvestIds: string[] }; + // Aggregate HoneyBatch. + HoneyBatchRecorded: { extractionId: string }; + HoneyBatchCorrected: { extractionId: string }; + // Aggregate PhenologyReport; apiaryId null = all apiaries. + PhenologyReported: { + apiaryId: string | null; + phase: PhenologyPhase; + reportedOn: string; + }; + PhenologyCorrected: { + apiaryId: string | null; + phase: PhenologyPhase; + reportedOn: string; + }; + // Aggregate Apiary; time-based (dedupeKey per apiary and day). + SeasonPhaseChanged: { + apiaryId: string; + phase: PhenologyPhase | null; + segment: SeasonSegment; + previousPhase: PhenologyPhase | null; + previousSegment: SeasonSegment | null; + source: PhaseSource | null; + }; } export type DomainEventType = keyof DomainEventPayloads; @@ -120,6 +149,13 @@ const CATALOG: Record = { InspectionOverdue: true, ColonySplit: true, QueenAssigned: true, + ExtractionRecorded: true, + ExtractionCorrected: true, + HoneyBatchRecorded: true, + HoneyBatchCorrected: true, + PhenologyReported: true, + PhenologyCorrected: true, + SeasonPhaseChanged: true, }; export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( @@ -138,7 +174,11 @@ export type AggregateType = | 'BatchMovement' | 'ShopStockRequest' | 'Task' - | 'Queen'; + | 'Queen' + | 'Extraction' + | 'HoneyBatch' + | 'PhenologyReport' + | 'Apiary'; export interface NewDomainEvent { type: T; diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts index 8d19f9e..7762325 100644 --- a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts +++ b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts @@ -183,7 +183,7 @@ describe('DomainEventsService', () => { describe('domain event catalog', () => { it('lists every event type exactly once', () => { - expect(DOMAIN_EVENT_TYPES).toHaveLength(25); + expect(DOMAIN_EVENT_TYPES).toHaveLength(32); expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length); expect(DOMAIN_EVENT_TYPES).toEqual( expect.arrayContaining([ @@ -193,6 +193,13 @@ describe('domain event catalog', () => { 'InspectionOverdue', 'ColonySplit', 'QueenAssigned', + 'ExtractionRecorded', + 'ExtractionCorrected', + 'HoneyBatchRecorded', + 'HoneyBatchCorrected', + 'PhenologyReported', + 'PhenologyCorrected', + 'SeasonPhaseChanged', ]), ); }); diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts index ba04f6e..4ac10c3 100644 --- a/ApiCore-Backend/src/prisma/row-lock.spec.ts +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -236,6 +236,10 @@ describe('lock keys per table', () => { 'Treatment', 'VarroaMeasurement', 'NotificationPreference', + 'Apiary', + 'PhenologyReport', + 'Extraction', + 'HoneyBatch', ]); }); }); diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts index 84333b5..0cae215 100644 --- a/ApiCore-Backend/src/prisma/row-lock.ts +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -32,6 +32,12 @@ const LOCKABLE_TABLES = { VarroaMeasurement: lockable('VarroaMeasurement'), // One row per user, keyed by the user (the table arrives with Task 3). NotificationPreference: lockable('NotificationPreference', 'userId'), + // T4b tables (Task 23): the apiary season state, phenology reports and the + // honey chain (extraction, honey batch ledger). + Apiary: lockable('Apiary'), + PhenologyReport: lockable('PhenologyReport'), + Extraction: lockable('Extraction'), + HoneyBatch: lockable('HoneyBatch'), } as const; export type LockableTable = keyof typeof LOCKABLE_TABLES; diff --git a/ApiCore-Backend/src/season/season-enums.spec.ts b/ApiCore-Backend/src/season/season-enums.spec.ts new file mode 100644 index 0000000..7c2d4e4 --- /dev/null +++ b/ApiCore-Backend/src/season/season-enums.spec.ts @@ -0,0 +1,18 @@ +import { + PhaseSource, + PhenologyPhase, + SeasonSegment, +} from '../../generated/prisma'; +import { + PHASE_SOURCES, + PHENOLOGY_PHASES, + SEASON_SEGMENTS, +} from './season-types'; + +describe('season enums', () => { + it('Prisma phenology enums equal the TS constants of T4a', () => { + expect(Object.values(PhenologyPhase)).toEqual([...PHENOLOGY_PHASES]); + expect(Object.values(SeasonSegment)).toEqual([...SEASON_SEGMENTS]); + expect(Object.values(PhaseSource)).toEqual([...PHASE_SOURCES]); + }); +}); diff --git a/ApiCore-Backend/test/honey-ledger-migration.e2e-spec.ts b/ApiCore-Backend/test/honey-ledger-migration.e2e-spec.ts new file mode 100644 index 0000000..1d2d5d5 --- /dev/null +++ b/ApiCore-Backend/test/honey-ledger-migration.e2e-spec.ts @@ -0,0 +1,390 @@ +import { + ScratchDatabase, + createScratchDatabase, +} from './utils/migration-harness'; +import { INVARIANT_SQL } from './utils/honey-ledger'; + +const PHENOLOGY = '20261015100000_t4_phenology'; +const HONEY_LEDGER = '20261015100100_t4_honey_ledger'; +const PRODUCT_SHELF_LIFE = '20261015100200_t4_product_shelf_life'; + +const TIMEOUT_MS = 240_000; + +async function seedUser(db: ScratchDatabase): Promise { + await db.query( + `INSERT INTO "Role" ("id", "key", "name", "updatedAt") + VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`, + ); + await db.query( + `INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt") + VALUES ('user-1', 'mig@example.com', 'x', 'Mig', 'role-1', now())`, + ); +} + +async function seedHoney( + db: ScratchDatabase, + batches: { id: string; amount: number | null }[], +): Promise { + await seedUser(db); + await db.query( + `INSERT INTO "Extraction" ("id", "date", "userId") VALUES ('ext-1', now(), 'user-1')`, + ); + for (const batch of batches) { + await db.query( + `INSERT INTO "HoneyBatch" ("id", "batchNumber", "extractionId", "amount", "updatedAt") + VALUES ($1, $1, 'ext-1', $2, now())`, + [batch.id, batch.amount], + ); + } +} + +async function seedBottling( + db: ScratchDatabase, + id: string, + honeyBatchId: string, + totalAmount: number, + createdAt: string, +): Promise { + await db.query( + `INSERT INTO "BottlingBatch" + ("id", "honeyBatchId", "date", "jarSize", "jarCount", "totalAmount", + "bestBeforeDate", "lotNumber", "createdAt") + VALUES ($1, $2, $4, 500, 10, $3, '2028-01-01', $1, $4)`, + [id, honeyBatchId, totalAmount, createdAt], + ); +} + +describe('T4 schema III migrations (e2e)', () => { + let db: ScratchDatabase | null = null; + + afterEach(async () => { + if (db) { + await db.drop(); + db = null; + } + }); + + it( + 'legacy bottlings keep amount = initialAmount + movements', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); + await seedBottling(db, 'bt-1', 'hb-1', 7.5, '2026-08-01T10:00:00Z'); + await seedBottling(db, 'bt-2', 'hb-1', 2.5, '2026-08-02T10:00:00Z'); + + await db.migrateThrough(HONEY_LEDGER); + + expect( + await db.query('SELECT "initialAmount", "amount" FROM "HoneyBatch"'), + ).toEqual([{ initialAmount: 30, amount: 20 }]); + const movements = await db.query<{ + type: string; + quantity: number; + honeyBatchId: string; + sourceBottlingBatchId: string; + notes: string; + sameTime: boolean; + }>( + `SELECT m."type"::text AS type, m."quantity", m."honeyBatchId", + m."sourceBottlingBatchId", m."notes", + (m."createdAt" = b."createdAt") AS "sameTime" + FROM "BatchMovement" m + JOIN "BottlingBatch" b ON b."id" = m."sourceBottlingBatchId" + ORDER BY m."quantity"`, + ); + expect(movements).toEqual([ + { + type: 'BOTTLING', + quantity: -7500, + honeyBatchId: 'hb-1', + sourceBottlingBatchId: 'bt-1', + notes: 'Übernahme aus Abfüllung (T4)', + sameTime: true, + }, + { + type: 'BOTTLING', + quantity: -2500, + honeyBatchId: 'hb-1', + sourceBottlingBatchId: 'bt-2', + notes: 'Übernahme aus Abfüllung (T4)', + sameTime: true, + }, + ]); + expect(await db.query(INVARIANT_SQL)).toEqual([]); + }, + TIMEOUT_MS, + ); + + it( + 'a batch without amount gets no initialAmount and no booking', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: null }]); + await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); + + await db.migrateThrough(HONEY_LEDGER); + + expect( + await db.query('SELECT "initialAmount" FROM "HoneyBatch"'), + ).toEqual([{ initialAmount: null }]); + expect(await db.query('SELECT 1 FROM "BatchMovement"')).toEqual([]); + }, + TIMEOUT_MS, + ); + + it( + 'a batch without bottlings gets initialAmount = amount and no booking', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 12.345 }]); + + await db.migrateThrough(HONEY_LEDGER); + + expect( + await db.query('SELECT "initialAmount" FROM "HoneyBatch"'), + ).toEqual([{ initialAmount: 12.345 }]); + expect(await db.query('SELECT 1 FROM "BatchMovement"')).toEqual([]); + }, + TIMEOUT_MS, + ); + + it( + 'a movement belongs to exactly one of the four batch kinds', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); + await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); + await db.migrateThrough(HONEY_LEDGER); + + await db.query( + `INSERT INTO "BatchMovement" ("id", "honeyBatchId", "type", "quantity") + VALUES ('m-ok', 'hb-1', 'CORRECTION', 100)`, + ); + await expect( + db.query( + `INSERT INTO "BatchMovement" ("id", "honeyBatchId", "bottlingBatchId", "type", "quantity") + VALUES ('m-two', 'hb-1', 'bt-1', 'CORRECTION', 100)`, + ), + ).rejects.toThrow(); + await expect( + db.query( + `INSERT INTO "BatchMovement" ("id", "type", "quantity") + VALUES ('m-none', 'CORRECTION', 100)`, + ), + ).rejects.toThrow(); + }, + TIMEOUT_MS, + ); + + it( + 'a bottling delete keeps its movements and clears the back reference', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); + await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); + await db.migrateThrough(HONEY_LEDGER); + + await db.query(`DELETE FROM "BottlingBatch" WHERE "id" = 'bt-1'`); + + expect( + await db.query( + `SELECT "sourceBottlingBatchId" FROM "BatchMovement" WHERE "honeyBatchId" = 'hb-1'`, + ), + ).toEqual([{ sourceBottlingBatchId: null }]); + }, + TIMEOUT_MS, + ); + + it( + 'rounds each booking half up to the gram and keeps initialAmount consistent with it', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 1 }]); + await seedBottling(db, 'bt-1', 'hb-1', 0.0125, '2026-08-01T10:00:00Z'); + + await db.migrateThrough(HONEY_LEDGER); + + expect(await db.query('SELECT "quantity" FROM "BatchMovement"')).toEqual([ + { quantity: -13 }, + ]); + expect(await db.query(INVARIANT_SQL)).toEqual([]); + }, + TIMEOUT_MS, + ); + + it( + 'a mixed run keeps the invariant for every batch', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [ + { id: 'hb-1', amount: 20.0004 }, + { id: 'hb-2', amount: null }, + { id: 'hb-3', amount: 3.3333 }, + { id: 'hb-4', amount: 8 }, + ]); + await seedBottling(db, 'bt-1', 'hb-1', 0.0125, '2026-08-01T10:00:00Z'); + await seedBottling(db, 'bt-2', 'hb-1', 4.4446, '2026-08-02T10:00:00Z'); + await seedBottling(db, 'bt-3', 'hb-1', 0.6667, '2026-08-03T10:00:00Z'); + await seedBottling(db, 'bt-4', 'hb-2', 2, '2026-08-04T10:00:00Z'); + await seedBottling(db, 'bt-5', 'hb-3', 1.1111, '2026-08-05T10:00:00Z'); + + await db.migrateThrough(HONEY_LEDGER); + + expect(await db.query(INVARIANT_SQL)).toEqual([]); + expect( + await db.query( + 'SELECT count(*)::int AS n FROM "BatchMovement" WHERE "honeyBatchId" IS NOT NULL', + ), + ).toEqual([{ n: 4 }]); + }, + TIMEOUT_MS, + ); + + it( + 'a movement that already belongs to a bottling survives the CHECK and is not double-booked', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); + await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); + await db.query( + `INSERT INTO "BatchMovement" ("id", "bottlingBatchId", "type", "quantity") + VALUES ('m-shop', 'bt-1', 'SHOP_SALE', -3)`, + ); + + await db.migrateThrough(HONEY_LEDGER); + + expect( + await db.query( + `SELECT "id", "honeyBatchId" FROM "BatchMovement" WHERE "bottlingBatchId" = 'bt-1'`, + ), + ).toEqual([{ id: 'm-shop', honeyBatchId: null }]); + expect( + await db.query( + `SELECT "quantity" FROM "BatchMovement" WHERE "honeyBatchId" = 'hb-1'`, + ), + ).toEqual([{ quantity: -5000 }]); + }, + TIMEOUT_MS, + ); + + it( + 'a bottling without a user migrates with a null userId', + async () => { + db = await createScratchDatabase(HONEY_LEDGER); + await seedHoney(db, [{ id: 'hb-1', amount: 20 }]); + await seedBottling(db, 'bt-1', 'hb-1', 5, '2026-08-01T10:00:00Z'); + + await db.migrateThrough(HONEY_LEDGER); + + expect(await db.query('SELECT "userId" FROM "BatchMovement"')).toEqual([ + { userId: null }, + ]); + }, + TIMEOUT_MS, + ); + + it( + 'indexes phenology reports by apiary, phase and date', + async () => { + db = await createScratchDatabase(PHENOLOGY); + await db.migrateThrough(PHENOLOGY); + + const rows = await db.query<{ indexdef: string }>( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = current_schema() AND tablename = 'PhenologyReport' AND indexname = 'PhenologyReport_apiaryId_phase_date_idx'`, + ); + expect(rows).toHaveLength(1); + }, + TIMEOUT_MS, + ); + + it( + 'phenology rights go to non-system roles: read with colonies:read, create/update with inspections:create, system roles get all', + async () => { + db = await createScratchDatabase(PHENOLOGY); + for (const [id, isSystem] of [ + ['r-reader', false], + ['r-keeper', false], + ['r-none', false], + ['r-system', true], + ] as const) { + await db.query( + `INSERT INTO "Role" ("id", "key", "name", "isSystem", "updatedAt") + VALUES ($1, $1, $1, $2, now())`, + [id, isSystem], + ); + } + // colonies:read and inspections:create come from earlier migrations. + const grant = (roleId: string, key: string) => + db!.query( + `INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt") + SELECT $1, "id", now() FROM "Permission" WHERE "key" = $2`, + [roleId, key], + ); + await grant('r-reader', 'colonies:read'); + await grant('r-keeper', 'colonies:read'); + await grant('r-keeper', 'inspections:create'); + await grant('r-system', 'colonies:read'); + await grant('r-system', 'inspections:create'); + + await db.migrateThrough(PHENOLOGY); + + expect( + await db.query( + `SELECT p."key", p."groupDe", p."sortOrder" FROM "Permission" p + WHERE p."resource" = 'phenology' ORDER BY p."sortOrder"`, + ), + ).toEqual([ + { + key: 'phenology:read', + groupDe: 'Pflanzenentwicklung', + sortOrder: 860, + }, + { + key: 'phenology:create', + groupDe: 'Pflanzenentwicklung', + sortOrder: 861, + }, + { + key: 'phenology:update', + groupDe: 'Pflanzenentwicklung', + sortOrder: 862, + }, + ]); + expect( + await db.query( + `SELECT rp."roleId", p."key" FROM "RolePermission" rp + JOIN "Permission" p ON p."id" = rp."permissionId" + WHERE p."resource" = 'phenology' AND rp."roleId" LIKE 'r-%' + ORDER BY rp."roleId", p."key"`, + ), + ).toEqual([ + { roleId: 'r-keeper', key: 'phenology:create' }, + { roleId: 'r-keeper', key: 'phenology:read' }, + { roleId: 'r-keeper', key: 'phenology:update' }, + { roleId: 'r-reader', key: 'phenology:read' }, + { roleId: 'r-system', key: 'phenology:create' }, + { roleId: 'r-system', key: 'phenology:read' }, + { roleId: 'r-system', key: 'phenology:update' }, + ]); + }, + TIMEOUT_MS, + ); + + it( + 'Product.shelfLifeMonths is nullable and has no default', + async () => { + db = await createScratchDatabase(PRODUCT_SHELF_LIFE); + await db.query( + `INSERT INTO "Product" ("id", "name", "updatedAt") VALUES ('p-1', 'Honig', now())`, + ); + + await db.migrateThrough(PRODUCT_SHELF_LIFE); + + expect(await db.query('SELECT "shelfLifeMonths" FROM "Product"')).toEqual( + [{ shelfLifeMonths: null }], + ); + }, + TIMEOUT_MS, + ); +}); diff --git a/ApiCore-Backend/test/jest-e2e.json b/ApiCore-Backend/test/jest-e2e.json index 517dff8..c87d0b3 100644 --- a/ApiCore-Backend/test/jest-e2e.json +++ b/ApiCore-Backend/test/jest-e2e.json @@ -5,6 +5,7 @@ "testRegex": ".e2e-spec.ts$", "setupFiles": ["/setup-env.ts"], "maxWorkers": 4, + "testTimeout": 20000, "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/ApiCore-Backend/test/utils/honey-ledger.ts b/ApiCore-Backend/test/utils/honey-ledger.ts new file mode 100644 index 0000000..93fd0be --- /dev/null +++ b/ApiCore-Backend/test/utils/honey-ledger.ts @@ -0,0 +1,19 @@ +/** + * Honey ledger invariant (Spec 4.6): amount = initialAmount + sum(quantity) + * / 1000, rounded to the gram. Returns the batches that break it (tolerance + * 0.5 g); an empty result means the ledger is consistent. Batches without + * initialAmount are not booked and therefore not checked. + */ +export const INVARIANT_SQL = ` + SELECT hb."id" + FROM "HoneyBatch" hb + LEFT JOIN ( + SELECT "honeyBatchId", sum("quantity") AS grams + FROM "BatchMovement" + WHERE "honeyBatchId" IS NOT NULL + GROUP BY "honeyBatchId" + ) 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.grams, 0) / 1000.0)) > 0.0005 +`;