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
@@ -34,6 +34,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'phenologyReport',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
@@ -118,6 +119,9 @@ describe('BackupService', () => {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
} as never;
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||
@@ -1451,6 +1455,169 @@ describe('BackupService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('phenology and honey ledger', () => {
|
||||
const importData = (data: Record<string, Row[]>, trusted = true) =>
|
||||
service.importAll(
|
||||
{ version: 3, exportedAt: '2026-01-01T00:00:00.000Z', data },
|
||||
'admin-1',
|
||||
{ trusted },
|
||||
);
|
||||
const created = (key: string): Row[] =>
|
||||
(tx[key].upsert.mock.calls as [{ create: Row }][]).map(
|
||||
([arg]) => arg.create,
|
||||
);
|
||||
|
||||
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', async () => {
|
||||
prisma.phenologyReport.findMany.mockResolvedValue([
|
||||
{ id: 'p1', phase: 'KIRSCHE', userId: 'known-user' },
|
||||
]);
|
||||
|
||||
const bundle = await service.exportAll();
|
||||
|
||||
expect(bundle.data.phenologyReport).toHaveLength(1);
|
||||
expect(bundle.data).not.toHaveProperty('apiaryPhenology');
|
||||
expect(bundle.data).not.toHaveProperty('apiaryWeatherDay');
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryPhenology).toMatch(
|
||||
/recomputed by daily-evaluation/,
|
||||
);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP.ApiaryWeatherDay).toMatch(
|
||||
/refetched by the worker/,
|
||||
);
|
||||
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
|
||||
});
|
||||
|
||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||
await importData({
|
||||
apiary: [{ id: 'apiary-1' }],
|
||||
phenologyReport: [
|
||||
{ id: 'p1', apiaryId: 'apiary-1', userId: 'known-user' },
|
||||
{ id: 'p2', apiaryId: null, userId: 'ghost' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(created('phenologyReport').map((row) => row.userId)).toEqual([
|
||||
'known-user',
|
||||
'admin-1',
|
||||
]);
|
||||
expect(tx.apiary.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.phenologyReport.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('an old bundle without initialAmount is recalculated like migration 6', async () => {
|
||||
await importData({
|
||||
honeyBatch: [
|
||||
{ id: 'hb-1', amount: 20 },
|
||||
{ id: 'hb-2', amount: null },
|
||||
{ id: 'hb-3', amount: 5 },
|
||||
],
|
||||
bottlingBatch: [
|
||||
{
|
||||
id: 'bt-1',
|
||||
honeyBatchId: 'hb-1',
|
||||
totalAmount: 7.5,
|
||||
createdAt: '2026-08-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'bt-2',
|
||||
honeyBatchId: 'hb-1',
|
||||
totalAmount: 2.0004,
|
||||
createdAt: '2026-08-02T10:00:00.000Z',
|
||||
},
|
||||
{ id: 'bt-3', honeyBatchId: 'hb-2', totalAmount: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
created('honeyBatch').map((row) => [row.id, row.initialAmount]),
|
||||
).toEqual([
|
||||
['hb-1', 29.5],
|
||||
['hb-2', undefined],
|
||||
['hb-3', 5],
|
||||
]);
|
||||
expect(created('batchMovement')).toEqual([
|
||||
expect.objectContaining({
|
||||
honeyBatchId: 'hb-1',
|
||||
sourceBottlingBatchId: 'bt-1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -7500,
|
||||
createdAt: '2026-08-01T10:00:00.000Z',
|
||||
notes: 'Übernahme aus Abfüllung (T4)',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
honeyBatchId: 'hb-1',
|
||||
sourceBottlingBatchId: 'bt-2',
|
||||
type: 'BOTTLING',
|
||||
quantity: -2000,
|
||||
}),
|
||||
]);
|
||||
// After the batch and the bottling it points at.
|
||||
expect(tx.bottlingBatch.upsert.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.batchMovement.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a bundle that carries initialAmount untouched', async () => {
|
||||
await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
||||
bottlingBatch: [{ id: 'bt-1', honeyBatchId: 'hb-1', totalAmount: 10 }],
|
||||
batchMovement: [
|
||||
{
|
||||
id: 'm1',
|
||||
honeyBatchId: 'hb-1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -10000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(created('honeyBatch')[0].initialAmount).toBe(30);
|
||||
expect(created('batchMovement').map((row) => row.id)).toEqual(['m1']);
|
||||
});
|
||||
|
||||
it('sets initialAmount from an unsealed file but books no movements', async () => {
|
||||
await importData(
|
||||
{
|
||||
honeyBatch: [{ id: 'hb-new', amount: 20 }],
|
||||
bottlingBatch: [
|
||||
{ id: 'bt-2', honeyBatchId: 'hb-new', totalAmount: 5 },
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(created('honeyBatch')[0].initialAmount).toBe(25);
|
||||
expect(tx.batchMovement.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports honey batches that break the invariant after the import, without failing it', async () => {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'hb-1' }]);
|
||||
|
||||
const summary = await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 30 }],
|
||||
});
|
||||
|
||||
expect(summary.imported.honeyLedgerViolations).toBe(1);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hb-1'));
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('adds no violation count when the ledger is consistent', async () => {
|
||||
(tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const summary = await importData({
|
||||
honeyBatch: [{ id: 'hb-1', amount: 20, initialAmount: 20 }],
|
||||
});
|
||||
|
||||
expect(summary.imported).not.toHaveProperty('honeyLedgerViolations');
|
||||
});
|
||||
});
|
||||
|
||||
// Drift guard: a model added to the schema has to be backed up or listed
|
||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||
describe('model coverage', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
openBackup,
|
||||
sealBackup,
|
||||
} from './backup-crypto';
|
||||
import { legacyHoneyLedger } from './legacy-honey-ledger';
|
||||
|
||||
// 3: production, raw material, stock ledger, number sequence, calendar sync
|
||||
// and backup schedule tables. Older bundles simply lack those sections and
|
||||
@@ -71,6 +72,9 @@ export interface BackupImportSummary {
|
||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||
* public links.
|
||||
* - document: writes files to the server's disk.
|
||||
* - honeyBatch.initialAmount: an unsealed file may set it (derived from
|
||||
* its bottlings) but never books ledger rows - batchMovement stays sealed
|
||||
* only, so the BOTTLING bookings of an old bundle come with sealed files.
|
||||
* - notification, notificationPreference: messages shown to users as coming
|
||||
* from ApiCore, and settings of a user.
|
||||
*/
|
||||
@@ -140,6 +144,8 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
'Delivery log and retry queue - restoring it would resend old events to receivers.',
|
||||
JobRun: "Run history of this deployment's CronJobs, meaningless elsewhere.",
|
||||
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
|
||||
ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.',
|
||||
ApiaryWeatherDay: 'Weather cache, refetched by the worker.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
@@ -244,6 +250,32 @@ export class BackupService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Imported honey batches whose amount differs from the ledger by more than 0.5 g. */
|
||||
private async honeyLedgerViolations(
|
||||
tx: Prisma.TransactionClient,
|
||||
ids: string[],
|
||||
): Promise<string[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rows = await tx.$queryRaw<{ id: string }[]>(Prisma.sql`
|
||||
SELECT hb."id"
|
||||
FROM "HoneyBatch" hb
|
||||
LEFT JOIN (
|
||||
SELECT "honeyBatchId", sum("quantity") AS grams
|
||||
FROM "BatchMovement"
|
||||
WHERE "honeyBatchId" = ANY(${ids})
|
||||
GROUP BY "honeyBatchId"
|
||||
) m ON m."honeyBatchId" = hb."id"
|
||||
WHERE hb."id" = ANY(${ids})
|
||||
AND hb."initialAmount" IS NOT NULL
|
||||
AND hb."amount" IS NOT NULL
|
||||
AND abs(hb."amount" - (hb."initialAmount" + coalesce(m.grams, 0) / 1000.0)) > 0.0005
|
||||
ORDER BY hb."id"
|
||||
`);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async exportAll(): Promise<BackupBundle> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
@@ -282,6 +314,7 @@ export class BackupService {
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
data.winterCheck = await this.prisma.winterCheck.findMany();
|
||||
data.phenologyReport = await this.prisma.phenologyReport.findMany();
|
||||
data.treatment = await this.prisma.treatment.findMany();
|
||||
data.feeding = await this.prisma.feeding.findMany();
|
||||
data.harvest = await this.prisma.harvest.findMany();
|
||||
@@ -459,6 +492,17 @@ export class BackupService {
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const violations = await this.honeyLedgerViolations(
|
||||
tx,
|
||||
rowsOf('honeyBatch').map((row) => row.id as string),
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
imported.honeyLedgerViolations = violations.length;
|
||||
this.logger.warn(
|
||||
`Honigcharge(n) mit verletzter Mengen-Invariante nach dem Import: ${violations.join(', ')}`,
|
||||
);
|
||||
}
|
||||
imported.rolePermission = rowsOf('rolePermission').length;
|
||||
|
||||
// Built after role/user import so a user id that only exists
|
||||
@@ -709,6 +753,19 @@ export class BackupService {
|
||||
}
|
||||
imported.winterCheck = rowsOf('winterCheck').length;
|
||||
|
||||
for (const row of rowsOf('phenologyReport')) {
|
||||
const report = {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
};
|
||||
await tx.phenologyReport.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: report as never,
|
||||
update: updateWith(report) as never,
|
||||
});
|
||||
}
|
||||
imported.phenologyReport = rowsOf('phenologyReport').length;
|
||||
|
||||
for (const row of rowsOf('treatment')) {
|
||||
await tx.treatment.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -796,11 +853,25 @@ export class BackupService {
|
||||
}
|
||||
imported.storageContainer = rowsOf('storageContainer').length;
|
||||
|
||||
// Bundles from before the honey ledger: initialAmount is derived
|
||||
// like migration 6 does. Only a sealed file also gets the BOTTLING
|
||||
// bookings; an unsealed one may not write ledger rows.
|
||||
const legacyLedger = legacyHoneyLedger(
|
||||
rowsOf('honeyBatch'),
|
||||
rowsOf('bottlingBatch'),
|
||||
);
|
||||
for (const row of rowsOf('honeyBatch')) {
|
||||
const initialAmount = legacyLedger.initialAmounts.get(
|
||||
row.id as string,
|
||||
);
|
||||
const batch =
|
||||
!('initialAmount' in row) && initialAmount !== undefined
|
||||
? { ...row, initialAmount }
|
||||
: row;
|
||||
await tx.honeyBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: updateWith(row) as never,
|
||||
create: batch as never,
|
||||
update: updateWith(batch) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatch = rowsOf('honeyBatch').length;
|
||||
@@ -967,6 +1038,19 @@ export class BackupService {
|
||||
}
|
||||
imported.batchMovement = rowsOf('batchMovement').length;
|
||||
|
||||
// After the batches and bottlings they point at; never rewritten.
|
||||
for (const row of trusted ? legacyLedger.movements : []) {
|
||||
const movement = {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
};
|
||||
await tx.batchMovement.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: movement as never,
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rowsOf('equipment')) {
|
||||
await tx.equipment.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
LEGACY_BOTTLING_NOTE,
|
||||
legacyBottlingMovementId,
|
||||
legacyHoneyLedger,
|
||||
} from './legacy-honey-ledger';
|
||||
|
||||
describe('legacyHoneyLedger', () => {
|
||||
it('derives initialAmount = amount + bottlings and one BOTTLING booking per bottling in grams', () => {
|
||||
const ledger = legacyHoneyLedger(
|
||||
[{ id: 'hb', amount: 20 }],
|
||||
[
|
||||
{ id: 'a', honeyBatchId: 'hb', totalAmount: 7.5, createdAt: 't1' },
|
||||
{ id: 'b', honeyBatchId: 'hb', totalAmount: 2.5, createdAt: 't2' },
|
||||
],
|
||||
);
|
||||
|
||||
expect(ledger.initialAmounts.get('hb')).toBe(30);
|
||||
expect(ledger.movements).toEqual([
|
||||
{
|
||||
id: legacyBottlingMovementId('a'),
|
||||
honeyBatchId: 'hb',
|
||||
sourceBottlingBatchId: 'a',
|
||||
type: 'BOTTLING',
|
||||
quantity: -7500,
|
||||
notes: LEGACY_BOTTLING_NOTE,
|
||||
userId: null,
|
||||
createdAt: 't1',
|
||||
},
|
||||
expect.objectContaining({ quantity: -2500, createdAt: 't2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a batch with initialAmount (new format) and a batch without amount', () => {
|
||||
const ledger = legacyHoneyLedger(
|
||||
[
|
||||
{ id: 'new', amount: 5, initialAmount: 8 },
|
||||
{ id: 'none', amount: null },
|
||||
],
|
||||
[
|
||||
{ id: 'a', honeyBatchId: 'new', totalAmount: 3 },
|
||||
{ id: 'b', honeyBatchId: 'none', totalAmount: 3 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(ledger.initialAmounts.size).toBe(0);
|
||||
expect(ledger.movements).toEqual([]);
|
||||
});
|
||||
|
||||
it('rounds half up to the gram like the migration', () => {
|
||||
const ledger = legacyHoneyLedger(
|
||||
[{ id: 'hb', amount: 1 }],
|
||||
[{ id: 'a', honeyBatchId: 'hb', totalAmount: 0.0125 }],
|
||||
);
|
||||
|
||||
expect(ledger.movements[0].quantity).toBe(-13);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// Bundles written before the honey ledger (T4 migration 6) carry no
|
||||
// HoneyBatch.initialAmount and no BOTTLING movements. The import derives
|
||||
// both with the rule of the migration, so amount = initialAmount + sum of the
|
||||
// movements holds for the restored batches too.
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export const LEGACY_BOTTLING_NOTE = 'Übernahme aus Abfüllung (T4)';
|
||||
|
||||
export interface LegacyHoneyLedger {
|
||||
/** initialAmount per honey batch id of a legacy batch with a recorded amount. */
|
||||
initialAmounts: ReadonlyMap<string, number>;
|
||||
/** The BOTTLING bookings of those batches, one per bottling. */
|
||||
movements: Row[];
|
||||
}
|
||||
|
||||
function isLegacyBatch(row: Row): boolean {
|
||||
return !('initialAmount' in row);
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
// Sums of kilograms carry float noise; a microgram grid hides it.
|
||||
function roundKg(value: number): number {
|
||||
return Math.round(value * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
/** Id of the booking made for a bottling; stable so a repeated restore adds nothing. */
|
||||
export function legacyBottlingMovementId(bottlingBatchId: string): string {
|
||||
return `legacy-bottling:${bottlingBatchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param honeyBatches rows of the bundle's `honeyBatch` section
|
||||
* @param bottlingBatches rows of the bundle's `bottlingBatch` section
|
||||
*/
|
||||
export function legacyHoneyLedger(
|
||||
honeyBatches: readonly Row[],
|
||||
bottlingBatches: readonly Row[],
|
||||
): LegacyHoneyLedger {
|
||||
const initialAmounts = new Map<string, number>();
|
||||
for (const batch of honeyBatches) {
|
||||
const amount = asNumber(batch.amount);
|
||||
if (isLegacyBatch(batch) && amount !== null) {
|
||||
initialAmounts.set(batch.id as string, amount);
|
||||
}
|
||||
}
|
||||
|
||||
const movements: Row[] = [];
|
||||
for (const bottling of bottlingBatches) {
|
||||
const honeyBatchId = bottling.honeyBatchId as string;
|
||||
const base = initialAmounts.get(honeyBatchId);
|
||||
const total = asNumber(bottling.totalAmount);
|
||||
if (base === undefined || total === null) {
|
||||
continue;
|
||||
}
|
||||
// The same gram rounding as the booking, so the invariant holds exactly.
|
||||
const grams = Math.round(total * 1000);
|
||||
initialAmounts.set(honeyBatchId, roundKg(base + grams / 1000));
|
||||
movements.push({
|
||||
id: legacyBottlingMovementId(bottling.id as string),
|
||||
honeyBatchId,
|
||||
sourceBottlingBatchId: bottling.id,
|
||||
type: 'BOTTLING',
|
||||
quantity: -grams,
|
||||
notes: LEGACY_BOTTLING_NOTE,
|
||||
userId: bottling.userId ?? null,
|
||||
createdAt: bottling.createdAt,
|
||||
});
|
||||
}
|
||||
return { initialAmounts, movements };
|
||||
}
|
||||
Reference in new issue
Block a user