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[]
|
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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
`;
|
||||||
Reference in new issue
Block a user