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:
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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
|
||||
@@ -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<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> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
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<DomainEventType, true> = {
|
||||
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<T extends DomainEventType = DomainEventType> {
|
||||
type: T;
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -236,6 +236,10 @@ describe('lock keys per table', () => {
|
||||
'Treatment',
|
||||
'VarroaMeasurement',
|
||||
'NotificationPreference',
|
||||
'Apiary',
|
||||
'PhenologyReport',
|
||||
'Extraction',
|
||||
'HoneyBatch',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"setupFiles": ["<rootDir>/setup-env.ts"],
|
||||
"maxWorkers": 4,
|
||||
"testTimeout": 20000,
|
||||
"transform": {
|
||||
"^.+\\.(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
|
||||
`;
|
||||
Reference in new issue
Block a user