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