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
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user