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;
|
||||
Reference in new issue
Block a user