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[] feedings Feeding[]
harvests Harvest[] harvests Harvest[]
extractions Extraction[] extractions Extraction[]
phenologyReports PhenologyReport[]
honeyMeasurements HoneyMeasurement[] honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[] stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee") assignedTasks Task[] @relation("TaskAssignee")
@@ -240,11 +241,14 @@ model Apiary {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deactivatedAt DateTime? deactivatedAt DateTime?
colonies Colony[] colonies Colony[]
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
harvests Harvest[] harvests Harvest[]
treatments Treatment[] treatments Treatment[]
rawMaterialLots RawMaterialLot[] rawMaterialLots RawMaterialLot[]
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
@@index([status]) @@index([status])
} }
@@ -928,6 +932,9 @@ model HoneyBatch {
extraction Extraction @relation(fields: [extractionId], references: [id]) extraction Extraction @relation(fields: [extractionId], references: [id])
honeyType String? honeyType String?
amount Float? amount Float?
// Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000.
// null = Menge nicht erfasst, dann wird nicht gebucht.
initialAmount Float?
waterContent Float? waterContent Float?
storageContainerId String? storageContainerId String?
storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id]) storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id])
@@ -939,6 +946,7 @@ model HoneyBatch {
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[] relocations HoneyBatchRelocation[]
containerAllocations HoneyBatchContainerAllocation[] containerAllocations HoneyBatchContainerAllocation[]
movements BatchMovement[]
@@index([status]) @@index([status])
@@index([extractionId]) @@index([extractionId])
@@ -1105,18 +1113,20 @@ enum ShopCategory {
} }
model Product { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String? description String?
sku String? @unique sku String? @unique
packagingSize Float? packagingSize Float?
weight Float? weight Float?
salesUnit String? salesUnit String?
status ProductStatus @default(ACTIVE) // Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung).
kind ProductKind @default(BOTTLED_HONEY) shelfLifeMonths Int?
shopCategory ShopCategory @default(HONIG) status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now()) kind ProductKind @default(BOTTLED_HONEY)
updatedAt DateTime @updatedAt shopCategory ShopCategory @default(HONIG)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[] productionBatches ProductionBatch[]
@@ -1174,7 +1184,8 @@ model BottlingBatch {
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
movements BatchMovement[] movements BatchMovement[]
causedMovements BatchMovement[] @relation("BatchMovementSourceBottling")
@@index([honeyBatchId]) @@index([honeyBatchId])
@@index([productId]) @@index([productId])
@@ -1288,28 +1299,36 @@ enum WithdrawalReason {
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der // Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
// Migration). // Migration).
model BatchMovement { model BatchMovement {
id String @id @default(cuid()) id String @id @default(cuid())
bottlingBatchId String? bottlingBatchId String?
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
productionBatchId String? productionBatchId String?
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
rawMaterialLotId String? rawMaterialLotId String?
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
type BatchMovementType // 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. // Mit Vorzeichen: minus = raus, plus = rein.
quantity Int quantity Int
reason WithdrawalReason? reason WithdrawalReason?
notes String? notes String?
shopRequestId String? shopRequestId String?
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict) shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
userId String? userId String?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
apiKeyId String? apiKeyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([bottlingBatchId]) @@index([bottlingBatchId])
@@index([productionBatchId]) @@index([productionBatchId])
@@index([rawMaterialLotId]) @@index([rawMaterialLotId])
@@index([honeyBatchId])
@@index([sourceBottlingBatchId])
@@index([shopRequestId]) @@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id). // ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id]) @@index([createdAt, id])
@@ -1789,3 +1808,85 @@ model ThrottlerHit {
@@id([key, throttlerName]) @@id([key, throttlerName])
@@index([expiresAt]) @@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])
}
@@ -123,6 +123,18 @@ export const PERMISSION_CATALOG = [
], ],
850, 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( ...group(
'Behandlungen', 'Behandlungen',
'treatments', 'treatments',
@@ -34,6 +34,7 @@ const BUSINESS_ENTITY_KEYS = [
'varroaMeasurement', 'varroaMeasurement',
'swarmControlCheck', 'swarmControlCheck',
'winterCheck', 'winterCheck',
'phenologyReport',
'treatment', 'treatment',
'feeding', 'feeding',
'harvest', 'harvest',
@@ -118,6 +119,9 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }), upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
} as never; } as never;
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
.fn()
.mockResolvedValue([]);
tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null); tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1451,6 +1455,169 @@ describe('BackupService', () => {
}); });
}); });
describe('phenology and honey ledger', () => {
const importData = (data: Record<string, Row[]>, 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 // 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. // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => { describe('model coverage', () => {
+86 -2
View File
@@ -21,6 +21,7 @@ import {
openBackup, openBackup,
sealBackup, sealBackup,
} from './backup-crypto'; } from './backup-crypto';
import { legacyHoneyLedger } from './legacy-honey-ledger';
// 3: production, raw material, stock ledger, number sequence, calendar sync // 3: production, raw material, stock ledger, number sequence, calendar sync
// and backup schedule tables. Older bundles simply lack those sections and // 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 * - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links. * public links.
* - document: writes files to the server's disk. * - 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 * - notification, notificationPreference: messages shown to users as coming
* from ApiCore, and settings of a user. * 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.', 'Delivery log and retry queue - restoring it would resend old events to receivers.',
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.", JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.', 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<string>( const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -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<string[]> {
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<BackupBundle> { async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {}; const data: Record<string, unknown[]> = {};
@@ -282,6 +314,7 @@ export class BackupService {
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany(); data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany(); data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany(); data.winterCheck = await this.prisma.winterCheck.findMany();
data.phenologyReport = await this.prisma.phenologyReport.findMany();
data.treatment = await this.prisma.treatment.findMany(); data.treatment = await this.prisma.treatment.findMany();
data.feeding = await this.prisma.feeding.findMany(); data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany(); data.harvest = await this.prisma.harvest.findMany();
@@ -459,6 +492,17 @@ export class BackupService {
update: {}, 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; imported.rolePermission = rowsOf('rolePermission').length;
// Built after role/user import so a user id that only exists // Built after role/user import so a user id that only exists
@@ -709,6 +753,19 @@ export class BackupService {
} }
imported.winterCheck = rowsOf('winterCheck').length; 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')) { for (const row of rowsOf('treatment')) {
await tx.treatment.upsert({ await tx.treatment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -796,11 +853,25 @@ export class BackupService {
} }
imported.storageContainer = rowsOf('storageContainer').length; 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')) { 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({ await tx.honeyBatch.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
create: row as never, create: batch as never,
update: updateWith(row) as never, update: updateWith(batch) as never,
}); });
} }
imported.honeyBatch = rowsOf('honeyBatch').length; imported.honeyBatch = rowsOf('honeyBatch').length;
@@ -967,6 +1038,19 @@ export class BackupService {
} }
imported.batchMovement = rowsOf('batchMovement').length; 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')) { for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({ await tx.equipment.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -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);
});
});
@@ -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<string, unknown>;
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<string, number>;
/** 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<string, number>();
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 };
}
@@ -1,8 +1,11 @@
import { import {
ColonyCondition, ColonyCondition,
ColonyStatus, ColonyStatus,
PhaseSource,
PhenologyPhase,
Prisma, Prisma,
RawMaterialKind, RawMaterialKind,
SeasonSegment,
SplitQueenSource, SplitQueenSource,
TaskRelatedType, TaskRelatedType,
TaskStatus, TaskStatus,
@@ -89,6 +92,32 @@ export interface DomainEventPayloads {
assignedOn: string; assignedOn: string;
previousColonyId: string | null; 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; export type DomainEventType = keyof DomainEventPayloads;
@@ -120,6 +149,13 @@ const CATALOG: Record<DomainEventType, true> = {
InspectionOverdue: true, InspectionOverdue: true,
ColonySplit: true, ColonySplit: true,
QueenAssigned: 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( export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -138,7 +174,11 @@ export type AggregateType =
| 'BatchMovement' | 'BatchMovement'
| 'ShopStockRequest' | 'ShopStockRequest'
| 'Task' | 'Task'
| 'Queen'; | 'Queen'
| 'Extraction'
| 'HoneyBatch'
| 'PhenologyReport'
| 'Apiary';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> { export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T; type: T;
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => { describe('domain event catalog', () => {
it('lists every event type exactly once', () => { 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(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual( expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -193,6 +193,13 @@ describe('domain event catalog', () => {
'InspectionOverdue', 'InspectionOverdue',
'ColonySplit', 'ColonySplit',
'QueenAssigned', 'QueenAssigned',
'ExtractionRecorded',
'ExtractionCorrected',
'HoneyBatchRecorded',
'HoneyBatchCorrected',
'PhenologyReported',
'PhenologyCorrected',
'SeasonPhaseChanged',
]), ]),
); );
}); });
@@ -236,6 +236,10 @@ describe('lock keys per table', () => {
'Treatment', 'Treatment',
'VarroaMeasurement', 'VarroaMeasurement',
'NotificationPreference', 'NotificationPreference',
'Apiary',
'PhenologyReport',
'Extraction',
'HoneyBatch',
]); ]);
}); });
}); });
+6
View File
@@ -32,6 +32,12 @@ const LOCKABLE_TABLES = {
VarroaMeasurement: lockable('VarroaMeasurement'), VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3). // One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'), 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; } as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES; export type LockableTable = keyof typeof LOCKABLE_TABLES;
@@ -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]);
});
});
@@ -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<void> {
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', '[email protected]', 'x', 'Mig', 'role-1', now())`,
);
}
async function seedHoney(
db: ScratchDatabase,
batches: { id: string; amount: number | null }[],
): Promise<void> {
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<void> {
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,
);
});
+1
View File
@@ -5,6 +5,7 @@
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"setupFiles": ["<rootDir>/setup-env.ts"], "setupFiles": ["<rootDir>/setup-env.ts"],
"maxWorkers": 4, "maxWorkers": 4,
"testTimeout": 20000,
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
@@ -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
`;