feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base

- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

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 20:00:46 +02:00
1 parent 22382d53a2
commit 1971b2d9c4
90 files changed
+8431 -155

No files matched your search

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength } from 'class-validator';
const MAX_ID_LENGTH = 64;
export class HoneyBatchPrefillQuery {
@ApiProperty({ description: 'Die Schleuderung, aus der die Charge entsteht' })
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/)
extractionId!: string;
}
@@ -0,0 +1,18 @@
/**
* The honey batch as the API and the webhook `batch.created` carry it. One
* definition: the service answers with it and the webhook handler loads the
* batch with it, so both payloads keep the same shape.
*/
export const honeyBatchInclude = {
measurements: true,
storageContainer: true,
extraction: {
include: {
harvests: {
include: {
harvest: { include: { colonies: { include: { colony: true } } } },
},
},
},
},
} as const;
@@ -20,6 +20,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 { HoneyBatchPrefillQuery } from './dto/honey-batch-prefill.query';
import { HoneyBatchesService } from './honey-batches.service';
@ApiTags('honey-batches')
@@ -34,6 +35,15 @@ export class HoneyBatchesController {
return this.honeyBatchesService.findAll({ status });
}
// Before ':id', so 'prefill' is not read as an id. Needs the create
// permission, not read: it offers the same data the create flow needs, and
// POST answers it anyway (ruling, Task 28 review).
@RequirePermission('honey-batches:create')
@Get('prefill')
prefill(@Query() query: HoneyBatchPrefillQuery) {
return this.honeyBatchesService.prefill(query.extractionId);
}
@RequirePermission('honey-batches:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
describe('HoneyBatchesService', () => {
@@ -11,6 +11,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
@@ -19,6 +20,7 @@ describe('HoneyBatchesService', () => {
findUnique: jest.Mock;
update: jest.Mock;
};
extraction: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
honeyBatchContainerAllocation: {
@@ -28,8 +30,11 @@ describe('HoneyBatchesService', () => {
delete: jest.Mock;
};
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let automation: { generateNumber: jest.Mock };
const extraction = {
id: 'extraction-1',
@@ -49,6 +54,7 @@ describe('HoneyBatchesService', () => {
const honeyBatch = {
id: 'batch-1',
batchNumber: 'HC-2026-001',
extractionId: 'extraction-1',
honeyType: 'Blütenhonig',
amount: 20,
status: 'RAW',
@@ -61,6 +67,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
@@ -69,6 +76,7 @@ describe('HoneyBatchesService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
extraction: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
honeyBatchContainerAllocation: {
@@ -77,16 +85,21 @@ describe('HoneyBatchesService', () => {
upsert: jest.fn(),
delete: jest.fn(),
},
$transaction: jest.fn(),
$transaction: jest.fn(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
),
$queryRaw: jest.fn().mockResolvedValue([]),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
events = { record: jest.fn() };
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
@@ -97,20 +110,77 @@ describe('HoneyBatchesService', () => {
);
});
it('create stores the batch and returns it with relations', async () => {
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
describe('create', () => {
const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' };
await service.create(
{ batchNumber: 'HC-2026-001', extractionId: 'extraction-1' },
'user-1',
);
beforeEach(() => {
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: { batchNumber: string; extractionId: string } }],
];
expect(createArg.data.batchNumber).toBe('HC-2026-001');
expect(createArg.data.extractionId).toBe('extraction-1');
it('stores the batch and returns it with relations and warnings', async () => {
const result = await service.create(dto, 'user-1');
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: { batchNumber: string; extractionId: string } }],
];
expect(createArg.data.batchNumber).toBe('HC-2026-001');
expect(createArg.data.extractionId).toBe('extraction-1');
expect(result).toEqual({ ...honeyBatch, warnings: [] });
});
it('writes the audit entry and HoneyBatchRecorded in the same transaction', async () => {
await service.create(dto, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
action: 'CREATE',
userId: 'user-1',
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(prisma, {
type: 'HoneyBatchRecorded',
aggregateType: 'HoneyBatch',
aggregateId: 'batch-1',
payload: { extractionId: 'extraction-1' },
actorUserId: 'user-1',
});
});
it('locks the extraction row before it writes and counts the batches', async () => {
await service.create(dto, 'user-1');
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.create.mock.invocationCallOrder[0],
);
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
);
});
it('warns when the batches exceed the extraction, but still answers', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 10,
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 6, amount: 6 },
{ id: 'batch-1', initialAmount: null, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
expect(result.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
});
it('traceability flattens the extraction, harvest, and colony chain', async () => {
@@ -365,11 +435,68 @@ describe('HoneyBatchesService', () => {
describe('update', () => {
beforeEach(() => {
prisma.$transaction.mockImplementation(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
it('locks the row first, reads "before" from the locked row and records HoneyBatchCorrected', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
const result = await service.update(
'batch-1',
{ honeyType: 'Waldhonig' },
'user-1',
);
// "Before" is the read after the last lock.
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
reads[reads.length - 1],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: honeyBatch,
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(prisma, {
type: 'HoneyBatchCorrected',
aggregateType: 'HoneyBatch',
aggregateId: 'batch-1',
payload: { extractionId: 'extraction-1' },
actorUserId: 'user-1',
});
expect(result.warnings).toEqual([]);
});
it('locks the extraction before the batch and before counting the batches', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
// Extraction lock, then batch lock: two statements, both before the
// update and the count of the batches.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
prisma.honeyBatch.update.mock.invocationCallOrder[0],
);
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
);
});
it('throws NotFoundException for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { honeyType: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.record).not.toHaveBeenCalled();
});
it('records a relocation when an edit corrects the storage container', async () => {
@@ -418,6 +545,7 @@ describe('HoneyBatchesService', () => {
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
});
@@ -433,6 +561,72 @@ describe('HoneyBatchesService', () => {
});
});
describe('prefill', () => {
it('proposes the rest of the extraction with its honey type and water content', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 42.5,
waterContent: 17.2,
harvests: [
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
],
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 20, amount: 20 },
{ id: 'batch-9', initialAmount: null, amount: null },
]);
const result = await service.prefill('extraction-1');
expect(result).toEqual({
remainingKg: 22.5,
honeyType: 'Bl\u00fctenhonig',
waterContent: 17.2,
warnings: [],
});
});
it('proposes no amount when the extraction has none and never a negative one', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
waterContent: null,
harvests: [{ harvest: { honeyType: null } }],
});
prisma.honeyBatch.findMany.mockResolvedValue([]);
const none = await service.prefill('extraction-1');
expect(none).toMatchObject({
remainingKg: null,
honeyType: null,
waterContent: null,
});
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 10,
waterContent: null,
harvests: [],
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 12, amount: 12 },
]);
const over = await service.prefill('extraction-1');
expect(over.remainingKg).toBe(0);
expect(over.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
it('throws NotFoundException for an unknown extraction', async () => {
prisma.extraction.findUnique.mockResolvedValue(null);
await expect(service.prefill('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
it('updateMeasurement corrects a reading and logs before/after', async () => {
const before = {
id: 'measurement-1',
@@ -1,12 +1,20 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
@@ -14,32 +22,43 @@ 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';
const honeyBatchInclude = {
measurements: true,
storageContainer: true,
extraction: {
include: {
harvests: {
include: {
harvest: { include: { colonies: { include: { colony: true } } } },
},
},
},
},
} as const;
import { honeyBatchInclude } from './honey-batch-include';
import {
batchWarnings,
roundKg,
sharedHoneyType,
sumInitialAmounts,
} from './honey-plausibility';
export interface HoneyBatchFilter {
status?: HoneyBatchStatus;
}
const PROPOSAL =
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
/** What the honey batch form may offer for an extraction (spec 7.6). */
export class HoneyBatchPrefill {
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
remainingKg!: number | null;
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
honeyType!: string | null;
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
waterContent!: number | null;
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
@Injectable()
export class HoneyBatchesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: HoneyBatchFilter = {}) {
@@ -61,6 +80,38 @@ export class HoneyBatchesService {
return honeyBatch;
}
/**
* The batches of one extraction with the amount they started with. A batch
* the ledger does not know yet (no initialAmount) counts with its current
* amount: it is the best known start, and the rest can only be lower.
*/
private async batchesOf(db: DbClient, extractionId: string) {
const batches = await db.honeyBatch.findMany({
where: { extractionId },
select: { id: true, initialAmount: true, amount: true },
orderBy: { id: 'asc' },
});
return batches.map((batch) => ({
id: batch.id,
initialAmount: batch.initialAmount ?? batch.amount,
}));
}
/** The batches of the extraction against its amount. Never blocks. */
private async warningsFor(
db: DbClient,
extractionId: string,
): Promise<PlausibilityWarning[]> {
const extraction = await db.extraction.findUnique({
where: { id: extractionId },
select: { id: true, amount: true },
});
if (extraction === null) {
return [];
}
return batchWarnings(extraction, await this.batchesOf(db, extractionId));
}
async create(dto: CreateHoneyBatchDto, actingUserId: string) {
const batchNumber =
dto.batchNumber ??
@@ -70,34 +121,83 @@ export class HoneyBatchesService {
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format" aktivieren)',
);
}
const honeyBatch = await this.prisma.honeyBatch.create({
data: { ...dto, batchNumber },
// Batch, audit entry and HoneyBatchRecorded commit together; the webhook
// batch.created follows from the event.
return this.prisma.$transaction(async (tx) => {
// The extraction first: batches of one extraction are counted against
// its amount, so two of them must not be written side by side.
await lockRowForUpdate(tx, 'Extraction', dto.extractionId);
const honeyBatch = await tx.honeyBatch.create({
data: { ...dto, batchNumber },
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchRecorded',
aggregateType: 'HoneyBatch',
aggregateId: honeyBatch.id,
payload: { extractionId: honeyBatch.extractionId },
actorUserId: actingUserId,
});
const created = await tx.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
include: honeyBatchInclude,
});
return withWarnings(
created,
await this.warningsFor(tx, created.extractionId),
);
});
await this.auditLog.record({
entityType: 'HoneyBatch',
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
});
const created = await this.findById(honeyBatch.id);
await this.webhooks.dispatch('batch.created', created);
return created;
}
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
const before = await this.findById(id);
// A relocation is a move into a container. null only removes the
// assignment - a batch may stand in no container, as it may be created
// without one - so it is recorded by the audit entry below, not as a
// relocation (which always needs a target container).
const movedToContainerId =
typeof dto.storageContainerId === 'string' &&
dto.storageContainerId !== before.storageContainerId
? dto.storageContainerId
: null;
return this.prisma.$transaction(async (tx) => {
// Lock order Extraction -> HoneyBatch. The extraction ids (current and
// a new one) come from an unlocked peek; the locked re-read below
// proves the peek still holds.
const peek = await tx.honeyBatch.findUnique({
where: { id },
select: { extractionId: true },
});
if (!peek) {
throw new NotFoundException('Honey batch not found');
}
await lockRowsForUpdate(tx, 'Extraction', [
...new Set([peek.extractionId, dto.extractionId ?? peek.extractionId]),
]);
// "Before" is read under the row lock, not before the transaction: a
// concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'HoneyBatch', id);
const before = await tx.honeyBatch.findUnique({
where: { id },
include: honeyBatchInclude,
});
if (!before) {
throw new NotFoundException('Honey batch not found');
}
if (before.extractionId !== peek.extractionId) {
throw new ConflictException(
'Die Honigcharge wurde gerade ge\u00e4ndert. Bitte erneut versuchen.',
);
}
// A relocation is a move into a container. null only removes the
// assignment - a batch may stand in no container, as it may be created
// without one - so it is recorded by the audit entry below, not as a
// relocation (which always needs a target container).
const movedToContainerId =
typeof dto.storageContainerId === 'string' &&
dto.storageContainerId !== before.storageContainerId
? dto.storageContainerId
: null;
await this.prisma.$transaction(async (tx) => {
await tx.honeyBatch.update({
where: { id },
data: { ...dto },
@@ -116,17 +216,69 @@ export class HoneyBatchesService {
},
});
}
const updated = await tx.honeyBatch.findUniqueOrThrow({
where: { id },
include: honeyBatchInclude,
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchCorrected',
aggregateType: 'HoneyBatch',
aggregateId: id,
payload: { extractionId: updated.extractionId },
actorUserId: actingUserId,
});
return withWarnings(
updated,
await this.warningsFor(tx, updated.extractionId),
);
});
const updated = await this.findById(id);
await this.auditLog.record({
entityType: 'HoneyBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
}
/**
* What the form may offer for a new batch of the extraction (spec 7.6): the
* rest of its amount after the existing batches, and its honey type (the
* one its harvests share) and water content. Proposals, never filled in
* silently. Read only, no lock.
*/
async prefill(extractionId: string): Promise<HoneyBatchPrefill> {
const extraction = await this.prisma.extraction.findUnique({
where: { id: extractionId },
select: {
id: true,
amount: true,
waterContent: true,
harvests: { select: { harvest: { select: { honeyType: true } } } },
},
});
return updated;
if (!extraction) {
throw new NotFoundException('Extraction not found');
}
const batches = await this.batchesOf(this.prisma, extractionId);
return {
remainingKg:
extraction.amount === null
? null
: Math.max(
0,
roundKg(extraction.amount - sumInitialAmounts(batches)),
),
honeyType: sharedHoneyType(
extraction.harvests.map((entry) => entry.harvest),
),
waterContent: extraction.waterContent,
warnings: batchWarnings(extraction, batches),
};
}
async updateMeasurement(
@@ -0,0 +1,128 @@
import {
batchWarnings,
extractionWarnings,
sharedHoneyType,
} from './honey-plausibility';
describe('extractionWarnings', () => {
it('warns when the extraction exceeds the known net harvest', () => {
const warnings = extractionWarnings(50, [
{ id: 'h1', netWeight: 20 },
{ id: 'h2', netWeight: 22.5 },
]);
expect(warnings).toEqual([
{
code: 'EXTRACTION_EXCEEDS_HARVEST',
severity: 'WARNUNG',
message:
'Die Schleudermenge (50 kg) liegt über dem Netto der Ernten (42,5 kg).',
refs: [
{ type: 'Harvest', id: 'h1' },
{ type: 'Harvest', id: 'h2' },
],
},
]);
});
it('stays silent when the extraction does not exceed the net harvest', () => {
expect(extractionWarnings(42.5, [{ id: 'h1', netWeight: 42.5 }])).toEqual(
[],
);
expect(extractionWarnings(10, [{ id: 'h1', netWeight: 42.5 }])).toEqual([]);
});
it('hints when a net weight is missing and does not compare then', () => {
const warnings = extractionWarnings(500, [
{ id: 'h1', netWeight: 20 },
{ id: 'h2', netWeight: null },
]);
expect(warnings).toEqual([
{
code: 'HARVEST_NET_MISSING',
severity: 'HINWEIS',
message:
'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
refs: [{ type: 'Harvest', id: 'h2' }],
},
]);
});
it('says how many harvests lack the net weight', () => {
const [warning] = extractionWarnings(null, [
{ id: 'h1', netWeight: null },
{ id: 'h2', netWeight: null },
]);
expect(warning.message).toBe(
'Bei 2 Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
);
});
it('has nothing to compare without an amount or without harvests', () => {
expect(extractionWarnings(null, [{ id: 'h1', netWeight: 5 }])).toEqual([]);
expect(extractionWarnings(5, [])).toEqual([]);
});
});
describe('batchWarnings', () => {
it('warns when the initial amounts of the batches exceed the extraction', () => {
const warnings = batchWarnings({ id: 'e1', amount: 40 }, [
{ id: 'b1', initialAmount: 25 },
{ id: 'b2', initialAmount: 20.5 },
]);
expect(warnings).toEqual([
{
code: 'BATCHES_EXCEED_EXTRACTION',
severity: 'WARNUNG',
message:
'Die Chargen (45,5 kg) übersteigen die Schleudermenge (40 kg).',
refs: [
{ type: 'Extraction', id: 'e1' },
{ type: 'HoneyBatch', id: 'b1' },
{ type: 'HoneyBatch', id: 'b2' },
],
},
]);
});
it('does not count batches without an initial amount and ignores an unknown extraction amount', () => {
expect(
batchWarnings({ id: 'e1', amount: 40 }, [
{ id: 'b1', initialAmount: 40 },
{ id: 'b2', initialAmount: null },
]),
).toEqual([]);
expect(
batchWarnings({ id: 'e1', amount: null }, [
{ id: 'b1', initialAmount: 400 },
]),
).toEqual([]);
});
it('tolerates float noise at the limit', () => {
expect(
batchWarnings({ id: 'e1', amount: 0.3 }, [
{ id: 'b1', initialAmount: 0.1 },
{ id: 'b2', initialAmount: 0.2 },
]),
).toEqual([]);
});
});
describe('sharedHoneyType', () => {
it('keeps the type all harvests share and nothing otherwise', () => {
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Linde' }]),
).toBe('Linde');
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Raps' }]),
).toBeNull();
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: null }]),
).toBeNull();
expect(sharedHoneyType([])).toBeNull();
});
});
@@ -0,0 +1,112 @@
import { formatNumberDe } from '../suggestions/texts/format-number';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
/** Half a gram in kg: the tolerance of the honey ledger (spec 4.6). */
const TOLERANCE_KG = 0.0005;
const MAX_FRACTION_DIGITS = 3;
const kgText = (value: number): string =>
`${formatNumberDe(value, MAX_FRACTION_DIGITS)} kg`;
/** The honey type all harvests share; null when they differ or one has none. */
export function sharedHoneyType(
harvests: readonly { honeyType: string | null }[],
): string | null {
const [first] = harvests;
if (first === undefined || first.honeyType === null) {
return null;
}
return harvests.every((harvest) => harvest.honeyType === first.honeyType)
? first.honeyType
: null;
}
/** kg on a gram grid, so sums of floats compare and print cleanly. */
export function roundKg(value: number): number {
return Math.round(value * 1000) / 1000;
}
/**
* The extraction (Schleuderung) against the net weight of its harvests
* (spec 7.6). The amount is compared only when every net weight is known;
* a missing one is a hint instead. Never blocks.
*/
export function extractionWarnings(
amountKg: number | null,
harvests: readonly { id: string; netWeight: number | null }[],
): PlausibilityWarning[] {
const missing = harvests.filter((harvest) => harvest.netWeight === null);
if (missing.length > 0) {
return [
{
code: 'HARVEST_NET_MISSING',
severity: WARNING_SEVERITY.HARVEST_NET_MISSING,
message:
missing.length === 1
? 'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.'
: `Bei ${missing.length} Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.`,
refs: missing.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
},
];
}
if (amountKg === null || harvests.length === 0) {
return [];
}
const netKg = roundKg(
harvests.reduce((sum, harvest) => sum + (harvest.netWeight ?? 0), 0),
);
if (amountKg <= netKg + TOLERANCE_KG) {
return [];
}
return [
{
code: 'EXTRACTION_EXCEEDS_HARVEST',
severity: WARNING_SEVERITY.EXTRACTION_EXCEEDS_HARVEST,
message: `Die Schleudermenge (${kgText(amountKg)}) liegt über dem Netto der Ernten (${kgText(netKg)}).`,
refs: harvests.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
},
];
}
/** Σ initial amount of the batches; a batch without one does not count. */
export function sumInitialAmounts(
batches: readonly { initialAmount: number | null }[],
): number {
return roundKg(
batches.reduce((sum, batch) => sum + (batch.initialAmount ?? 0), 0),
);
}
/**
* The honey batches of one extraction against its amount (spec 7.6): their
* initial amounts together must not exceed what was extracted. Nothing to
* compare without an extraction amount. Never blocks.
*/
export function batchWarnings(
extraction: { id: string; amount: number | null },
batches: readonly { id: string; initialAmount: number | null }[],
): PlausibilityWarning[] {
if (extraction.amount === null) {
return [];
}
const total = sumInitialAmounts(batches);
if (total <= extraction.amount + TOLERANCE_KG) {
return [];
}
return [
{
code: 'BATCHES_EXCEED_EXTRACTION',
severity: WARNING_SEVERITY.BATCHES_EXCEED_EXTRACTION,
message: `Die Chargen (${kgText(total)}) übersteigen die Schleudermenge (${kgText(extraction.amount)}).`,
refs: [
{ type: 'Extraction', id: extraction.id },
...batches
.filter((batch) => batch.initialAmount !== null)
.map((batch) => ({ type: 'HoneyBatch', id: batch.id })),
],
},
];
}