feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops

- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

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 22:47:17 +02:00
1 parent 16cc557462
commit fab7f7e12f
91 files changed
+8463 -104

No files matched your search

@@ -22,9 +22,11 @@ describe('HoneyBatchesService', () => {
};
honeyMeasurement: {
create: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
automationRule: { findUnique: jest.Mock };
extraction: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
@@ -85,9 +87,12 @@ describe('HoneyBatchesService', () => {
},
honeyMeasurement: {
create: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
update: jest.fn(),
},
// No rule row: the built-in water content limits apply.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
extraction: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
@@ -949,4 +954,45 @@ describe('HoneyBatchesService', () => {
service.updateMeasurement('missing', { waterContent: 17.5 }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
describe('water content warnings on a measurement', () => {
function measured(newest: number) {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
honeyType: null,
waterContent: null,
});
prisma.honeyMeasurement.create.mockResolvedValue({ id: 'm1' });
prisma.honeyMeasurement.findMany.mockResolvedValue([
{ waterContent: newest, measuredAt: new Date('2026-08-01') },
]);
}
it('addMeasurement above the legal limit answers a warning and never refuses', async () => {
measured(21);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 21, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(prisma.honeyMeasurement.create).toHaveBeenCalledTimes(1);
});
it('a good measurement answers no warning', async () => {
measured(16.5);
const result = await service.addMeasurement(
'batch-1',
{ waterContent: 16.5, measuredAt: '2026-08-01T10:00:00.000Z' },
'user-1',
);
expect(result.warnings).toEqual([]);
});
});
});
@@ -28,6 +28,7 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { waterContentWarningsOfBatch } from '../common/quality/water-content';
import { honeyBatchInclude } from './honey-batch-include';
import {
batchWarnings,
@@ -403,6 +404,17 @@ export class HoneyBatchesService {
};
}
/**
* The honey batch as the answer to a measurement, with the water content
* findings of its newest value (spec 7.8). Only a report: a measurement is
* a fact and is never refused or held back for a confirmation.
*/
private async withWaterWarnings(honeyBatchId: string) {
const batch = await this.findById(honeyBatchId);
const water = await waterContentWarningsOfBatch(this.prisma, honeyBatchId);
return withWarnings(batch, water?.warnings ?? []);
}
async updateMeasurement(
measurementId: string,
dto: UpdateHoneyMeasurementDto,
@@ -429,7 +441,7 @@ export class HoneyBatchesService {
beforeData: before,
afterData: measurement,
});
return this.findById(before.honeyBatchId);
return this.withWaterWarnings(before.honeyBatchId);
}
async addMeasurement(
@@ -453,7 +465,7 @@ export class HoneyBatchesService {
userId: actingUserId,
afterData: measurement,
});
return this.findById(id);
return this.withWaterWarnings(id);
}
async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) {