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,64 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
ExtractionPrefillQuery,
MAX_PREFILL_HARVESTS,
} from './extraction-prefill.query';
async function check(query: Record<string, unknown>) {
const dto = plainToInstance(ExtractionPrefillQuery, query);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return {
dto,
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
};
}
describe('ExtractionPrefillQuery', () => {
it('splits the comma-separated ids and drops empty segments', async () => {
const { dto, messages } = await check({ harvestIds: 'ha,hb,' });
expect(messages).toEqual([]);
expect(dto.harvestIds).toEqual(['ha', 'hb']);
});
it('accepts a repeated query parameter', async () => {
const { dto, messages } = await check({ harvestIds: ['ha', 'hb'] });
expect(messages).toEqual([]);
expect(dto.harvestIds).toEqual(['ha', 'hb']);
});
it('requires at least one id', async () => {
expect((await check({})).messages).not.toEqual([]);
expect((await check({ harvestIds: ',' })).messages).toContain(
'harvestIds darf nicht leer sein',
);
});
it('allows at most 50 ids', async () => {
const ids = Array.from(
{ length: MAX_PREFILL_HARVESTS + 1 },
(_, i) => `h${i}`,
);
expect((await check({ harvestIds: ids.join(',') })).messages).toContain(
`harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
);
expect(
(await check({ harvestIds: ids.slice(0, 50).join(',') })).messages,
).toEqual([]);
});
it('rejects duplicates and ids with odd characters', async () => {
expect((await check({ harvestIds: 'ha,ha' })).messages).toContain(
'harvestIds enthält eine Ernte doppelt',
);
expect((await check({ harvestIds: 'ha,h b' })).messages).toContain(
'harvestIds enthält eine ungültige Ernte-ID',
);
});
});
@@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export const MAX_PREFILL_HARVESTS = 50;
const MAX_ID_LENGTH = 64;
const IDS_MESSAGE = 'harvestIds enthält eine ungültige Ernte-ID';
// "a,b" and the repeated form harvestIds=a&harvestIds=b both become a list;
// empty segments carry no id and are dropped.
function toIdList({ value }: TransformFnParams): unknown {
const parts: unknown =
typeof value === 'string' ? value.split(',') : (value as unknown);
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
}
export class ExtractionPrefillQuery {
@ApiProperty({
type: String,
description: `Kommagetrennte Ernte-IDs, 1 bis ${MAX_PREFILL_HARVESTS}`,
example: 'harvest-a,harvest-b',
})
@Transform(toIdList)
@IsArray({ message: IDS_MESSAGE })
@ArrayMinSize(1, { message: 'harvestIds darf nicht leer sein' })
@ArrayMaxSize(MAX_PREFILL_HARVESTS, {
message: `harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
})
@ArrayUnique({ message: 'harvestIds enthält eine Ernte doppelt' })
@IsString({ each: true, message: IDS_MESSAGE })
@MaxLength(MAX_ID_LENGTH, { each: true, message: IDS_MESSAGE })
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: IDS_MESSAGE })
harvestIds!: string[];
}
@@ -1,9 +1,18 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateExtractionDto } from './dto/create-extraction.dto';
import { ExtractionPrefillQuery } from './dto/extraction-prefill.query';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
import { ExtractionsService } from './extractions.service';
@@ -19,6 +28,15 @@ export class ExtractionsController {
return this.extractionsService.findAll();
}
// 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('extractions:create')
@Get('prefill')
prefill(@Query() query: ExtractionPrefillQuery) {
return this.extractionsService.prefill(query.harvestIds);
}
@RequirePermission('extractions:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { ExtractionsController } from './extractions.controller';
import { ExtractionsService } from './extractions.service';
@Module({
imports: [WithdrawalModule],
controllers: [ExtractionsController],
providers: [ExtractionsService],
exports: [ExtractionsService],
@@ -2,32 +2,59 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExtractionsService } from './extractions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
const entry = (harvestId: string, netWeight: number | null) => ({
harvestId,
harvest: { id: harvestId, netWeight },
});
describe('ExtractionsService', () => {
let service: ExtractionsService;
let prisma: {
extraction: { findUnique: jest.Mock; update: jest.Mock };
extraction: { findUnique: jest.Mock };
harvest: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let withdrawalCheck: {
conflictsForExtraction: jest.Mock;
conflictsForHarvests: jest.Mock;
warningsFor: jest.Mock;
};
let tx: {
extraction: { create: jest.Mock; update: jest.Mock };
extraction: {
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
$queryRaw: jest.Mock;
};
const extraction = { id: 'extraction-1', date: new Date() };
const extraction = {
id: 'extraction-1',
date: new Date(),
amount: null as number | null,
harvests: [entry('harvest-1', 20), entry('harvest-2', 22.5)],
};
beforeEach(() => {
tx = {
extraction: {
create: jest.fn().mockResolvedValue(extraction),
update: jest.fn(),
findUnique: jest.fn().mockResolvedValue(extraction),
findUniqueOrThrow: jest.fn().mockResolvedValue(extraction),
},
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
extraction: { findUnique: jest.fn(), update: jest.fn() },
extraction: { findUnique: jest.fn() },
harvest: {
findMany: jest
.fn()
@@ -38,9 +65,17 @@ describe('ExtractionsService', () => {
),
};
auditLog = { record: jest.fn() };
events = { record: jest.fn() };
withdrawalCheck = {
conflictsForExtraction: jest.fn().mockResolvedValue([]),
conflictsForHarvests: jest.fn().mockResolvedValue([]),
warningsFor: jest.fn().mockResolvedValue([]),
};
service = new ExtractionsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
withdrawalCheck as unknown as WithdrawalCheckService,
);
});
@@ -52,8 +87,6 @@ describe('ExtractionsService', () => {
});
it('create links every harvest to the extraction inside a transaction', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
await service.create(
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
@@ -68,6 +101,61 @@ describe('ExtractionsService', () => {
]);
});
it('create writes the audit entry and ExtractionRecorded in the same transaction', async () => {
await service.create(
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Extraction',
entityId: 'extraction-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ExtractionRecorded',
aggregateType: 'Extraction',
aggregateId: 'extraction-1',
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
actorUserId: 'user-1',
});
});
it('create answers the extraction with withdrawal and plausibility warnings, never an error', async () => {
const conflict = { harvestId: 'harvest-1' };
withdrawalCheck.conflictsForExtraction.mockResolvedValue([conflict]);
withdrawalCheck.warningsFor.mockResolvedValue([
{ code: 'WITHDRAWAL_ACTIVE' },
]);
tx.extraction.findUniqueOrThrow.mockResolvedValue({
...extraction,
amount: 50,
});
const result = await service.create(
{
date: '2026-07-05',
harvestIds: ['harvest-1', 'harvest-2'],
amount: 50,
},
'user-1',
);
expect(withdrawalCheck.conflictsForExtraction).toHaveBeenCalledWith(
tx,
'extraction-1',
);
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(tx, [conflict]);
expect(result.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
'EXTRACTION_EXCEEDS_HARVEST',
]);
});
it('create rejects an unknown harvest id instead of failing on the DB foreign-key constraint', async () => {
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
@@ -82,9 +170,8 @@ describe('ExtractionsService', () => {
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...extraction, notes: 'geändert' };
prisma.extraction.findUnique
.mockResolvedValueOnce(extraction)
.mockResolvedValueOnce(updated);
tx.extraction.findUnique.mockResolvedValueOnce(extraction);
tx.extraction.findUniqueOrThrow.mockResolvedValueOnce(updated);
const result = await service.update(
'extraction-1',
@@ -92,7 +179,7 @@ describe('ExtractionsService', () => {
'user-1',
);
expect(result).toEqual(updated);
expect(result).toEqual({ ...updated, warnings: [] });
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Extraction',
@@ -101,13 +188,49 @@ describe('ExtractionsService', () => {
beforeData: extraction,
afterData: updated,
}),
tx,
);
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
it('update locks the row first and takes "before" from the locked row', async () => {
const locked = { ...extraction, notes: 'gesperrt gelesen' };
tx.extraction.findUnique.mockResolvedValue(locked);
await service.update('extraction-1', { notes: 'neu' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.extraction.findUnique.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update throws NotFoundException for an unknown extraction', async () => {
tx.extraction.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.record).not.toHaveBeenCalled();
});
it('update records ExtractionCorrected with the harvests after the change', async () => {
await service.update('extraction-1', { notes: 'x' }, 'user-1');
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ExtractionCorrected',
aggregateType: 'Extraction',
aggregateId: 'extraction-1',
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
actorUserId: 'user-1',
});
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
await service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'harvest-2'] },
@@ -127,7 +250,6 @@ describe('ExtractionsService', () => {
});
it('update rejects an unknown harvest id before touching the database', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
await expect(
@@ -139,4 +261,100 @@ describe('ExtractionsService', () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
describe('prefill', () => {
const harvestRow = (
id: string,
netWeight: number | null,
honeyType: string | null,
) => ({
id,
date: new Date('2026-07-05T00:00:00.000Z'),
netWeight,
honeyType,
});
it('sums the net weights and keeps a shared honey type', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, 'Blütenhonig'),
]);
const result = await service.prefill(['harvest-1', 'harvest-2']);
expect(result).toEqual({
amountKg: 42.5,
honeyType: 'Blütenhonig',
sources: [
{ harvestId: 'harvest-1', date: '2026-07-05', netWeight: 20 },
{ harvestId: 'harvest-2', date: '2026-07-05', netWeight: 22.5 },
],
warnings: [],
});
});
it('warns before saving when a chosen harvest lies in a withdrawal window', async () => {
const conflict = { harvestId: 'harvest-1' };
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, null),
]);
withdrawalCheck.conflictsForHarvests.mockResolvedValue([conflict]);
withdrawalCheck.warningsFor.mockResolvedValue([
{ code: 'WITHDRAWAL_ACTIVE' },
]);
const result = await service.prefill(['harvest-1']);
expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(
prisma,
['harvest-1'],
);
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(prisma, [
conflict,
]);
expect(result.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
]);
});
it('offers no honey type when the harvests differ or one has none', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, 'Waldhonig'),
]);
const differing = await service.prefill(['harvest-1', 'harvest-2']);
expect(differing.honeyType).toBeNull();
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, null),
]);
const partly = await service.prefill(['harvest-1', 'harvest-2']);
expect(partly.honeyType).toBeNull();
});
it('offers no amount when a net weight is missing and hints instead', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, null),
harvestRow('harvest-2', null, null),
]);
const result = await service.prefill(['harvest-1', 'harvest-2']);
expect(result.amountKg).toBeNull();
expect(result.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('rejects an unknown harvest id', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 1, null),
]);
await expect(
service.prefill(['harvest-1', 'missing-harvest']),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});
@@ -3,8 +3,23 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinDayOf } from '../common/dates';
import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import {
extractionWarnings,
roundKg,
sharedHoneyType,
} from '../honey-batches/honey-plausibility';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { CreateExtractionDto } from './dto/create-extraction.dto';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
@@ -16,11 +31,37 @@ const extractionInclude = {
},
} as const;
const PROPOSAL =
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
export class ExtractionPrefillSource {
@ApiProperty() harvestId!: string;
@ApiProperty({ description: 'Berliner Tag, JJJJ-MM-TT' }) date!: string;
@ApiProperty({ type: Number, nullable: true }) netWeight!: number | null;
}
/** What the extraction form may offer from the chosen harvests (spec 7.6). */
export class ExtractionPrefill {
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
amountKg!: number | null;
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
honeyType!: string | null;
@ApiProperty({ type: [ExtractionPrefillSource] })
sources!: ExtractionPrefillSource[];
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
@Injectable()
export class ExtractionsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly withdrawalCheck: WithdrawalCheckService,
) {}
async findAll() {
@@ -55,9 +96,42 @@ export class ExtractionsService {
}
}
/**
* The extraction as the response body plus its warnings: a harvest inside
* a withdrawal window and the amount against the net weight of the
* harvests. Warnings inform, they never block (Annahme 10). The
* withdrawal warning goes to everyone who records the extraction, whether
* or not they may read treatments (ruling: food safety beats the read
* split); the text names only colony, product and end day.
*/
private async withAllWarnings<
T extends {
id: string;
amount: number | null;
harvests: { harvestId: string; harvest: { netWeight: number | null } }[];
},
>(db: DbClient, extraction: T) {
const conflicts = await this.withdrawalCheck.conflictsForExtraction(
db,
extraction.id,
);
return withWarnings(extraction, [
...(await this.withdrawalCheck.warningsFor(db, conflicts)),
...extractionWarnings(
extraction.amount,
extraction.harvests.map((entry) => ({
id: entry.harvestId,
netWeight: entry.harvest.netWeight,
})),
),
]);
}
async create(dto: CreateExtractionDto, actingUserId: string) {
await this.assertHarvestsExist(dto.harvestIds);
const extraction = await this.prisma.$transaction(async (tx) => {
// Extraction, harvest links, audit entry and ExtractionRecorded commit
// together; the withdrawal notification follows from the event.
return this.prisma.$transaction(async (tx) => {
const created = await tx.extraction.create({
data: {
date: new Date(dto.date),
@@ -75,26 +149,48 @@ export class ExtractionsService {
harvestId,
})),
});
return created;
await this.auditLog.record(
{
entityType: 'Extraction',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'ExtractionRecorded',
aggregateType: 'Extraction',
aggregateId: created.id,
payload: { harvestIds: dto.harvestIds },
actorUserId: actingUserId,
});
const extraction = await tx.extraction.findUniqueOrThrow({
where: { id: created.id },
include: extractionInclude,
});
return this.withAllWarnings(tx, extraction);
});
await this.auditLog.record({
entityType: 'Extraction',
entityId: extraction.id,
action: 'CREATE',
userId: actingUserId,
afterData: extraction,
});
return this.findById(extraction.id);
}
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
const before = await this.findById(id);
const { harvestIds, ...scalarFields } = dto;
if (harvestIds) {
await this.assertHarvestsExist(harvestIds);
}
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
// "Before" is read under the row lock, not before the transaction: a
// concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'Extraction', id);
const before = await tx.extraction.findUnique({
where: { id },
include: extractionInclude,
});
if (!before) {
throw new NotFoundException('Extraction not found');
}
await tx.extraction.update({
where: { id },
data: {
@@ -113,17 +209,85 @@ export class ExtractionsService {
})),
});
}
const extraction = await tx.extraction.findUniqueOrThrow({
where: { id },
include: extractionInclude,
});
await this.auditLog.record(
{
entityType: 'Extraction',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: extraction,
},
tx,
);
await this.events.record(tx, {
type: 'ExtractionCorrected',
aggregateType: 'Extraction',
aggregateId: id,
payload: {
harvestIds: extraction.harvests.map((entry) => entry.harvestId),
},
actorUserId: actingUserId,
});
return this.withAllWarnings(tx, extraction);
});
}
const extraction = await this.findById(id);
await this.auditLog.record({
entityType: 'Extraction',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: extraction,
/**
* What the form may offer for the chosen harvests (spec 7.6). The amount is
* a measurement and so only a proposal: the sum of the net weights, and
* only when every one is known. Read only, no lock.
*/
async prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill> {
const ids = [...new Set(harvestIds)];
const harvests = await this.prisma.harvest.findMany({
where: { id: { in: ids } },
select: { id: true, date: true, netWeight: true, honeyType: true },
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return extraction;
const found = new Set(harvests.map((harvest) => harvest.id));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) {
throw new BadRequestException(
`Unknown harvest id(s): ${missing.join(', ')}`,
);
}
const conflicts = await this.withdrawalCheck.conflictsForHarvests(
this.prisma,
ids,
);
const allNetKnown = harvests.every((harvest) => harvest.netWeight !== null);
return {
amountKg:
allNetKnown && harvests.length > 0
? roundKg(
harvests.reduce(
(sum, harvest) => sum + (harvest.netWeight ?? 0),
0,
),
)
: null,
honeyType: sharedHoneyType(harvests),
sources: harvests.map((harvest) => ({
harvestId: harvest.id,
date: berlinDayOf(harvest.date),
netWeight: harvest.netWeight,
})),
// The same warnings the save will answer, so the form can warn first.
warnings: [
...(await this.withdrawalCheck.warningsFor(this.prisma, conflicts)),
...extractionWarnings(
null,
harvests.map((harvest) => ({
id: harvest.id,
netWeight: harvest.netWeight,
})),
),
],
};
}
}