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:
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,
|
||||
})),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user