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

@@ -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', () => {
+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 },
@@ -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 };
}