feat: T4b wave 7 - schema for phenology, honey movement ledger, shelf life

- Phenology reports, apiary phenology state and weather days with
  permissions for system roles
- Honey movement ledger: BOTTLING movements and initialAmount backfilled
  from existing bottlings with the same gram rounding; the migration
  reports counts and aborts if the ledger invariant fails
- Shelf life and event catalog columns; backup export/import of the new
  tables, old bundles keep importing, invariant check after imports
- e2e: test timeout 20 s (app start in hooks exceeded 5 s under load)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 19:19:32 +02:00
1 parent 326b3d9cdd
commit 22382d53a2
17 files changed
+1213 -39

No files matched your search

+86 -2
View File
@@ -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 },