feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
NotEquals,
|
||||
} from 'class-validator';
|
||||
import { WithdrawalReason } from '../../../generated/prisma';
|
||||
|
||||
/** The limit of one booking, in kg: far above any batch, below float noise. */
|
||||
export const MAX_MOVEMENT_KG = 100000;
|
||||
|
||||
export const MOVEMENT_TYPES = ['WITHDRAWAL', 'CORRECTION'] as const;
|
||||
export type ManualMovementType = (typeof MOVEMENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* A manual booking in the movement book of a honey batch. Bottlings book
|
||||
* themselves; this is for loose goods, samples, breakage (WITHDRAWAL, minus)
|
||||
* and for weighing again (CORRECTION, plus or minus).
|
||||
*/
|
||||
export class CreateHoneyBatchMovementDto {
|
||||
@ApiProperty({ enum: MOVEMENT_TYPES })
|
||||
@IsIn(MOVEMENT_TYPES)
|
||||
type!: ManualMovementType;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Änderung der Restmenge in kg mit Vorzeichen, nicht 0 (Entnahme: negativ)',
|
||||
})
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@NotEquals(0)
|
||||
@Min(-MAX_MOVEMENT_KG)
|
||||
@Max(MAX_MOVEMENT_KG)
|
||||
deltaKg!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: WithdrawalReason,
|
||||
description: 'Pflicht bei einer Entnahme',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WithdrawalReason)
|
||||
reason?: WithdrawalReason;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
|
||||
// HoneyBatch.amount is the running rest of the movement book (spec 7.7): only
|
||||
// HoneyBatchLedgerService writes it. A second writer would break the
|
||||
// invariant amount = initialAmount + sum(quantity) / 1000.
|
||||
//
|
||||
// The regexes see an `amount` inside the call itself. A spread such as
|
||||
// `data: { ...dto }` is invisible to them; honey-batches.service.spec.ts
|
||||
// covers that case.
|
||||
const SRC = join(__dirname, '..');
|
||||
const LEDGER = 'src/honey-batches/honey-batch-ledger.service.ts';
|
||||
|
||||
function productionSources(dir: string): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return productionSources(path);
|
||||
}
|
||||
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||
? [path]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function writersOf(...patterns: RegExp[]): string[] {
|
||||
return productionSources(SRC)
|
||||
.filter((file) => {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
return patterns.some((pattern) => pattern.test(source));
|
||||
})
|
||||
.map((file) => `src/${relative(SRC, file).split(sep).join('/')}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('honey batch amount writes', () => {
|
||||
it('honey batch amount is written only by the ledger', () =>
|
||||
expect(
|
||||
writersOf(
|
||||
/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s,
|
||||
/UPDATE "HoneyBatch"[^;]*"amount"/,
|
||||
),
|
||||
).toEqual([LEDGER]));
|
||||
|
||||
it('the guard sees a write: the ledger itself is found', () =>
|
||||
expect(writersOf(/honeyBatch\.update\(\{[^)]*data: \{ amount/s)).toContain(
|
||||
LEDGER,
|
||||
));
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
interface BatchState {
|
||||
amount: number | null;
|
||||
initialAmount: number | null;
|
||||
/** Sum of the booked grams so far. */
|
||||
grams: number;
|
||||
}
|
||||
|
||||
describe('HoneyBatchLedgerService', () => {
|
||||
let service: HoneyBatchLedgerService;
|
||||
let state: BatchState;
|
||||
let exists: boolean;
|
||||
let steps: string[];
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
|
||||
batchMovement: { aggregate: jest.Mock; create: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
batchMovement: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state = { amount: 25, initialAmount: 25, grams: 0 };
|
||||
exists = true;
|
||||
steps = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn(() => {
|
||||
steps.push('lock');
|
||||
return Promise.resolve(exists ? [{ id: 'h1' }] : []);
|
||||
}),
|
||||
honeyBatch: {
|
||||
findUnique: jest.fn(() => {
|
||||
steps.push('read');
|
||||
return Promise.resolve({
|
||||
amount: state.amount,
|
||||
initialAmount: state.initialAmount,
|
||||
});
|
||||
}),
|
||||
update: jest.fn(({ data }: { data: Partial<BatchState> }) => {
|
||||
steps.push('write');
|
||||
state = { ...state, ...data };
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
},
|
||||
batchMovement: {
|
||||
aggregate: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
_sum: { quantity: state.grams },
|
||||
_count: { _all: state.grams === 0 ? 0 : 1 },
|
||||
}),
|
||||
),
|
||||
create: jest.fn(({ data }: { data: { quantity: number } }) => {
|
||||
state = { ...state, grams: state.grams + data.quantity };
|
||||
return Promise.resolve({ id: 'm1', ...data });
|
||||
}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
batchMovement: { findMany: jest.fn() },
|
||||
};
|
||||
service = new HoneyBatchLedgerService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
const book = (
|
||||
deltaKg: number,
|
||||
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL' = 'BOTTLING',
|
||||
extra: Record<string, unknown> = {},
|
||||
) =>
|
||||
service.bookIn(tx as never, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg,
|
||||
type,
|
||||
actingUserId: 'user-1',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('books in grams and rounds the amount to the gram', async () => {
|
||||
const result = await book(-5.0004, 'BOTTLING', {
|
||||
sourceBottlingBatchId: 'b1',
|
||||
});
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
honeyBatchId: 'h1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
userId: 'user-1',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ amountKg: 20, movementId: 'm1' });
|
||||
expect(state.amount).toBe(20);
|
||||
});
|
||||
|
||||
it('rounds half a gram up with floor(x*1000+0.5), like the migration', async () => {
|
||||
await book(0.0125, 'CORRECTION');
|
||||
await book(-0.0125, 'CORRECTION');
|
||||
|
||||
const quantities = (
|
||||
tx.batchMovement.create.mock.calls as [{ data: { quantity: number } }][]
|
||||
).map(([arg]) => arg.data.quantity);
|
||||
expect(quantities).toEqual([13, -12]);
|
||||
});
|
||||
|
||||
it('books exactly the rest when it is off the gram grid, and ends at 0', async () => {
|
||||
state = { amount: 3.0004, initialAmount: 3.0004, grams: 0 };
|
||||
|
||||
const result = await book(-3.0004);
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ quantity: -3000 }) as unknown,
|
||||
});
|
||||
expect(result.amountKg).toBe(0);
|
||||
expect(state.grams).toBe(-3000);
|
||||
});
|
||||
|
||||
it('a reason on a correction is stored', async () => {
|
||||
await book(1, 'CORRECTION', { reason: 'SONSTIGES' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ reason: 'SONSTIGES' }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the batch before it reads it', async () => {
|
||||
await book(-1);
|
||||
|
||||
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('refuses a booking larger than the rest with the German message', async () => {
|
||||
state = { amount: 3, initialAmount: 25, grams: -22000 };
|
||||
|
||||
await expect(book(-6)).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
),
|
||||
);
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a booking within half a gram of the rest', async () => {
|
||||
state = { amount: 3, initialAmount: 25, grams: -22000 };
|
||||
|
||||
const result = await book(-3.0004);
|
||||
|
||||
expect(result.amountKg).toBe(0);
|
||||
});
|
||||
|
||||
it('a correction or withdrawal below zero says the amount cannot go below 0', async () => {
|
||||
state = { amount: 1, initialAmount: 1, grams: 0 };
|
||||
|
||||
await expect(book(-2, 'CORRECTION')).rejects.toThrow(
|
||||
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
|
||||
);
|
||||
await expect(book(-2, 'WITHDRAWAL')).rejects.toThrow(
|
||||
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives back with a plus booking and a note', async () => {
|
||||
state = { amount: 20, initialAmount: 25, grams: -5000 };
|
||||
|
||||
const result = await book(5, 'BOTTLING', { notes: 'Abfüllung entfernt' });
|
||||
|
||||
expect(result.amountKg).toBe(25);
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
quantity: 5000,
|
||||
notes: 'Abfüllung entfernt',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a correction books the difference new minus current', async () => {
|
||||
state = { amount: 20, initialAmount: 25, grams: -5000 };
|
||||
|
||||
await book(22.5 - 20, 'CORRECTION', { notes: 'Nachgewogen' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
type: 'CORRECTION',
|
||||
quantity: 2500,
|
||||
notes: 'Nachgewogen',
|
||||
}) as unknown,
|
||||
});
|
||||
expect(state.amount).toBe(22.5);
|
||||
});
|
||||
|
||||
it('a withdrawal carries its reason', async () => {
|
||||
await book(-1, 'WITHDRAWAL', { reason: 'EIGENBEDARF' });
|
||||
|
||||
expect(tx.batchMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ reason: 'EIGENBEDARF' }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the amount from the ledger sum, never from a stored value that drifted', async () => {
|
||||
state = { amount: 9.9, initialAmount: 10, grams: -2000 };
|
||||
|
||||
const result = await book(-1);
|
||||
|
||||
expect(result.amountKg).toBe(7);
|
||||
});
|
||||
|
||||
it('books nothing for a batch without initialAmount', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
const result = await book(-5);
|
||||
|
||||
expect(result).toEqual({ amountKg: null, movementId: null });
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('books nothing for a delta below one gram', async () => {
|
||||
const result = await book(0.0003);
|
||||
|
||||
expect(result).toEqual({ amountKg: 25, movementId: null });
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for a missing batch', async () => {
|
||||
exists = false;
|
||||
|
||||
await expect(book(-1)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('refuses the root client', async () => {
|
||||
await expect(
|
||||
service.bookIn({ $connect: jest.fn() } as never, {
|
||||
honeyBatchId: 'h1',
|
||||
deltaKg: -1,
|
||||
type: 'BOTTLING',
|
||||
actingUserId: null,
|
||||
}),
|
||||
).rejects.toThrow('needs the transaction client');
|
||||
});
|
||||
|
||||
describe('setInitialIn', () => {
|
||||
it('sets amount and initialAmount together, without a booking', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await service.setInitialIn(tx as never, 'h1', 12.5);
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 12.5, initialAmount: 12.5 },
|
||||
});
|
||||
expect(tx.batchMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rounds the amount to the gram, like a booking', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await service.setInitialIn(tx as never, 'h1', 12.3456);
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 12.346, initialAmount: 12.346 },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a batch that already has an initial amount', async () => {
|
||||
await expect(
|
||||
service.setInitialIn(tx as never, 'h1', 12.5),
|
||||
).rejects.toThrow('initialAmount already set');
|
||||
});
|
||||
|
||||
it('refuses a negative or non-finite amount', async () => {
|
||||
state = { amount: null, initialAmount: null, grams: 0 };
|
||||
|
||||
await expect(service.setInitialIn(tx as never, 'h1', -1)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.setInitialIn(tx as never, 'h1', Number.NaN),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearIn', () => {
|
||||
it('removes amount and initialAmount while nothing is booked', async () => {
|
||||
await service.clearIn(tx as never, 'h1');
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: null, initialAmount: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses with the ledger message once there are bookings', async () => {
|
||||
state = { ...state, grams: -5000 };
|
||||
|
||||
await expect(service.clearIn(tx as never, 'h1')).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
|
||||
),
|
||||
);
|
||||
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('movements', () => {
|
||||
it('lists the bookings as views, newest first', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
|
||||
const createdAt = new Date('2026-10-01T10:00:00Z');
|
||||
prisma.batchMovement.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'BOTTLING',
|
||||
quantity: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
shopRequestId: null,
|
||||
apiKeyId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.movements('h1');
|
||||
|
||||
expect(prisma.batchMovement.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { honeyBatchId: 'h1' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 201,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'BOTTLING',
|
||||
quantityGrams: -5000,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: 'b1',
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 200 bookings and says there are more', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
|
||||
const row = (i: number) => ({
|
||||
id: `m${i}`,
|
||||
type: 'BOTTLING',
|
||||
quantity: -1,
|
||||
reason: null,
|
||||
notes: null,
|
||||
sourceBottlingBatchId: null,
|
||||
createdAt: new Date(),
|
||||
userId: null,
|
||||
});
|
||||
prisma.batchMovement.findMany.mockResolvedValue(
|
||||
Array.from({ length: 201 }, (_, i) => row(i)),
|
||||
);
|
||||
|
||||
const result = await service.movements('h1');
|
||||
|
||||
expect(result.items).toHaveLength(200);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reads one booking through the given client', async () => {
|
||||
const createdAt = new Date('2026-10-01T10:00:00Z');
|
||||
const db = {
|
||||
batchMovement: {
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue({
|
||||
id: 'm2',
|
||||
type: 'WITHDRAWAL',
|
||||
quantity: -1500,
|
||||
reason: 'BRUCH',
|
||||
notes: null,
|
||||
sourceBottlingBatchId: null,
|
||||
createdAt,
|
||||
userId: 'user-1',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const view = await service.movementView(db as never, 'm2');
|
||||
|
||||
expect(db.batchMovement.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: 'm2' },
|
||||
});
|
||||
expect(view).toMatchObject({
|
||||
id: 'm2',
|
||||
quantityGrams: -1500,
|
||||
reason: 'BRUCH',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.movements('x')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Prisma,
|
||||
type BatchMovementType,
|
||||
type WithdrawalReason,
|
||||
} from '../../generated/prisma';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
/** Half a gram in kg: the tolerance of the ledger (spec 4.6). */
|
||||
const TOLERANCE_KG = 0.0005;
|
||||
const GRAMS_PER_KG = 1000;
|
||||
|
||||
export const INSUFFICIENT_AMOUNT_MESSAGE =
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
|
||||
export const BELOW_ZERO_MESSAGE = 'Die Menge kann nicht unter 0 sinken.';
|
||||
export const LEDGER_MANAGED_MESSAGE =
|
||||
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.';
|
||||
|
||||
export interface LedgerBooking {
|
||||
honeyBatchId: string;
|
||||
deltaKg: number;
|
||||
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL';
|
||||
reason?: WithdrawalReason | null;
|
||||
notes?: string | null;
|
||||
sourceBottlingBatchId?: string | null;
|
||||
actingUserId: string | null;
|
||||
}
|
||||
|
||||
export interface LedgerResult {
|
||||
/** The rest after the booking; null for a batch whose amount is not kept. */
|
||||
amountKg: number | null;
|
||||
/** null when nothing was booked. */
|
||||
movementId: string | null;
|
||||
}
|
||||
|
||||
export interface HoneyBatchMovementView {
|
||||
id: string;
|
||||
type: BatchMovementType;
|
||||
quantityGrams: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
function toView(row: {
|
||||
id: string;
|
||||
type: BatchMovementType;
|
||||
quantity: number;
|
||||
reason: WithdrawalReason | null;
|
||||
notes: string | null;
|
||||
sourceBottlingBatchId: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
}): HoneyBatchMovementView {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
quantityGrams: row.quantity,
|
||||
reason: row.reason,
|
||||
notes: row.notes,
|
||||
sourceBottlingBatchId: row.sourceBottlingBatchId,
|
||||
createdAt: row.createdAt,
|
||||
userId: row.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/** One rounding for every gram: floor(x * 1000 + 0.5), as in the migration. */
|
||||
function toGrams(kg: number): number {
|
||||
return Math.floor(kg * GRAMS_PER_KG + 0.5);
|
||||
}
|
||||
|
||||
/** A kg amount on the gram grid. */
|
||||
export function roundToGram(kg: number): number {
|
||||
return toGrams(kg) / GRAMS_PER_KG;
|
||||
}
|
||||
|
||||
/** Bookings returned by `movements`; more are reported, not sent. */
|
||||
export const MOVEMENTS_PAGE_SIZE = 200;
|
||||
|
||||
/** The rest implied by the ledger, on the gram grid, never below 0. */
|
||||
function restFromLedger(initialAmountKg: number, grams: number): number {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round((initialAmountKg + grams / GRAMS_PER_KG) * GRAMS_PER_KG) /
|
||||
GRAMS_PER_KG,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The movement book of a honey batch (spec 4.6, 7.7): the ONE place that
|
||||
* writes HoneyBatch.amount. Invariant: amount = initialAmount + sum(quantity)
|
||||
* / 1000, rounded to the gram, never below 0. A batch without initialAmount
|
||||
* is not booked (its amount is not recorded). Every method runs in the
|
||||
* caller's transaction, so the booking commits or rolls back together with
|
||||
* the business write behind it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HoneyBatchLedgerService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Books a change of the rest under the batch row lock. A negative delta
|
||||
* needs enough rest (half a gram of tolerance), a positive one gives back.
|
||||
* The stored amount is set from the ledger sum, so it cannot drift.
|
||||
*/
|
||||
async bookIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
booking: LedgerBooking,
|
||||
): Promise<LedgerResult> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.bookIn');
|
||||
const { honeyBatchId } = booking;
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const batch = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true, initialAmount: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (batch.initialAmount === null) {
|
||||
return { amountKg: null, movementId: null };
|
||||
}
|
||||
let grams = toGrams(booking.deltaKg);
|
||||
if (grams === 0) {
|
||||
return { amountKg: batch.amount, movementId: null };
|
||||
}
|
||||
const rest = batch.amount ?? 0;
|
||||
if (grams < 0 && rest < -grams / GRAMS_PER_KG - TOLERANCE_KG) {
|
||||
throw new BadRequestException(
|
||||
booking.type === 'BOTTLING'
|
||||
? INSUFFICIENT_AMOUNT_MESSAGE
|
||||
: BELOW_ZERO_MESSAGE,
|
||||
);
|
||||
}
|
||||
// Within the tolerance a booking takes exactly the rest on the gram
|
||||
// grid, so the sum of the bookings and the amount stay equal.
|
||||
const restGrams = toGrams(rest);
|
||||
if (grams < 0 && -grams > restGrams) {
|
||||
grams = -restGrams;
|
||||
}
|
||||
if (grams === 0) {
|
||||
return { amountKg: batch.amount, movementId: null };
|
||||
}
|
||||
const booked = await tx.batchMovement.aggregate({
|
||||
where: { honeyBatchId },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const amountKg = restFromLedger(
|
||||
batch.initialAmount,
|
||||
(booked._sum.quantity ?? 0) + grams,
|
||||
);
|
||||
const movement = await tx.batchMovement.create({
|
||||
data: {
|
||||
honeyBatchId,
|
||||
type: booking.type,
|
||||
quantity: grams,
|
||||
reason: booking.reason ?? null,
|
||||
notes: booking.notes ?? null,
|
||||
sourceBottlingBatchId: booking.sourceBottlingBatchId ?? null,
|
||||
userId: booking.actingUserId,
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: amountKg },
|
||||
});
|
||||
return { amountKg, movementId: movement.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* The first amount of a batch that had none: it becomes the rest and the
|
||||
* initial amount at once, without a booking. For a new batch with an amount
|
||||
* and for the PATCH from "not recorded" to a value.
|
||||
*/
|
||||
async setInitialIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
amountKg: number,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.setInitialIn');
|
||||
if (!Number.isFinite(amountKg) || amountKg < 0) {
|
||||
throw new BadRequestException('Die Menge muss eine Zahl ab 0 sein.');
|
||||
}
|
||||
const batch = await this.lockedBatch(tx, honeyBatchId);
|
||||
const rounded = roundToGram(amountKg);
|
||||
if (batch.initialAmount !== null) {
|
||||
throw new Error(
|
||||
`HoneyBatchLedgerService.setInitialIn: initialAmount already set for ${honeyBatchId}`,
|
||||
);
|
||||
}
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: rounded, initialAmount: rounded },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the amount away again ("not recorded"). Only while nothing is
|
||||
* booked: with bookings the amount is corrected, not erased.
|
||||
*/
|
||||
async clearIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'HoneyBatchLedgerService.clearIn');
|
||||
const batch = await this.lockedBatch(tx, honeyBatchId);
|
||||
if (batch.initialAmount === null) {
|
||||
return;
|
||||
}
|
||||
const booked = await tx.batchMovement.aggregate({
|
||||
where: { honeyBatchId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
if (booked._count._all > 0) {
|
||||
throw new BadRequestException(LEDGER_MANAGED_MESSAGE);
|
||||
}
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: null, initialAmount: null },
|
||||
});
|
||||
}
|
||||
|
||||
/** The bookings of one batch, newest first. */
|
||||
async movements(honeyBatchId: string): Promise<{
|
||||
items: HoneyBatchMovementView[];
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const batch = await this.prisma.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const rows = await this.prisma.batchMovement.findMany({
|
||||
where: { honeyBatchId },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: MOVEMENTS_PAGE_SIZE + 1,
|
||||
});
|
||||
return {
|
||||
items: rows.slice(0, MOVEMENTS_PAGE_SIZE).map(toView),
|
||||
hasMore: rows.length > MOVEMENTS_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
/** One booking, read through `db` (a transaction sees its own writes). */
|
||||
async movementView(
|
||||
db: DbClient,
|
||||
movementId: string,
|
||||
): Promise<HoneyBatchMovementView> {
|
||||
return toView(
|
||||
await db.batchMovement.findUniqueOrThrow({ where: { id: movementId } }),
|
||||
);
|
||||
}
|
||||
|
||||
private async lockedBatch(
|
||||
tx: Prisma.TransactionClient,
|
||||
honeyBatchId: string,
|
||||
) {
|
||||
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const batch = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true, initialAmount: true },
|
||||
});
|
||||
if (batch === null) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,25 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
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 { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@ApiBearerAuth()
|
||||
@Controller('honey-batches')
|
||||
export class HoneyBatchesController {
|
||||
constructor(private readonly honeyBatchesService: HoneyBatchesService) {}
|
||||
constructor(
|
||||
private readonly honeyBatchesService: HoneyBatchesService,
|
||||
private readonly ledger: HoneyBatchLedgerService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get()
|
||||
@@ -50,6 +56,24 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.findById(id);
|
||||
}
|
||||
|
||||
// The movement book: every change of the rest, newest first.
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id/movements')
|
||||
movements(@Param('id') id: string) {
|
||||
return this.ledger.movements(id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:update')
|
||||
@Idempotent()
|
||||
@Post(':id/movements')
|
||||
createMovement(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateHoneyBatchMovementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.createMovement(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('honey-batches:read')
|
||||
@Get(':id/traceability')
|
||||
traceability(@Param('id') id: string) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { HoneyBatchesController } from './honey-batches.controller';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [HoneyBatchesController],
|
||||
providers: [HoneyBatchesService],
|
||||
exports: [HoneyBatchesService],
|
||||
providers: [HoneyBatchesService, HoneyBatchLedgerService],
|
||||
exports: [HoneyBatchesService, HoneyBatchLedgerService],
|
||||
})
|
||||
export class HoneyBatchesModule {}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
|
||||
|
||||
describe('HoneyBatchesService', () => {
|
||||
let service: HoneyBatchesService;
|
||||
@@ -35,6 +40,13 @@ describe('HoneyBatchesService', () => {
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let automation: { generateNumber: jest.Mock };
|
||||
let ledger: {
|
||||
bookIn: jest.Mock;
|
||||
setInitialIn: jest.Mock;
|
||||
clearIn: jest.Mock;
|
||||
movements: jest.Mock;
|
||||
movementView: jest.Mock;
|
||||
};
|
||||
|
||||
const extraction = {
|
||||
id: 'extraction-1',
|
||||
@@ -95,11 +107,19 @@ describe('HoneyBatchesService', () => {
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn() };
|
||||
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||
ledger = {
|
||||
bookIn: jest.fn().mockResolvedValue({ amountKg: 20, movementId: 'm1' }),
|
||||
setInitialIn: jest.fn().mockResolvedValue(undefined),
|
||||
clearIn: jest.fn().mockResolvedValue(undefined),
|
||||
movements: jest.fn().mockResolvedValue([]),
|
||||
movementView: jest.fn(),
|
||||
};
|
||||
service = new HoneyBatchesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
ledger as unknown as HoneyBatchLedgerService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -172,7 +192,7 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([
|
||||
{ id: 'batch-0', initialAmount: 6, amount: 6 },
|
||||
{ id: 'batch-1', initialAmount: null, amount: 8 },
|
||||
{ id: 'batch-1', initialAmount: 8, amount: 8 },
|
||||
]);
|
||||
|
||||
const result = await service.create(dto, 'user-1');
|
||||
@@ -181,6 +201,53 @@ describe('HoneyBatchesService', () => {
|
||||
'BATCHES_EXCEED_EXTRACTION',
|
||||
]);
|
||||
});
|
||||
|
||||
it('create writes amount and initialAmount together, through the ledger', async () => {
|
||||
await service.create({ ...dto, amount: 20 }, 'user-1');
|
||||
|
||||
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
expect(createArg.data).not.toHaveProperty('amount');
|
||||
expect(createArg.data).not.toHaveProperty('initialAmount');
|
||||
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 20);
|
||||
});
|
||||
|
||||
it('the audit entry of a new batch shows the amount the ledger set', async () => {
|
||||
const withAmount = { ...honeyBatch, amount: 20, initialAmount: 20 };
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(withAmount);
|
||||
|
||||
await service.create({ ...dto, amount: 20 }, 'user-1');
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'CREATE', afterData: withAmount }),
|
||||
prisma,
|
||||
);
|
||||
expect(ledger.setInitialIn.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.honeyBatch.findUniqueOrThrow.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('create without an amount books nothing', async () => {
|
||||
await service.create(dto, 'user-1');
|
||||
|
||||
expect(ledger.setInitialIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a batch without initialAmount does not count against the extraction', 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).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('traceability flattens the extraction, harvest, and colony chain', async () => {
|
||||
@@ -561,6 +628,225 @@ describe('HoneyBatchesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update of the amount (movement book)', () => {
|
||||
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findMany.mockResolvedValue([]);
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
id: 'extraction-1',
|
||||
amount: null,
|
||||
});
|
||||
});
|
||||
|
||||
function dataOfUpdate(): Record<string, unknown> {
|
||||
const [[arg]] = prisma.honeyBatch.update.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
return arg.data;
|
||||
}
|
||||
|
||||
it('update never passes amount to Prisma, even when the dto carries it', async () => {
|
||||
await service.update(
|
||||
'batch-1',
|
||||
{ amount: 22, honeyType: 'Waldhonig' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(dataOfUpdate()).toEqual({ honeyType: 'Waldhonig' });
|
||||
});
|
||||
|
||||
it('books a CORRECTION for the difference new minus current', async () => {
|
||||
await service.update('batch-1', { amount: 22.5 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
|
||||
honeyBatchId: 'batch-1',
|
||||
deltaKg: 2.5,
|
||||
type: 'CORRECTION',
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds the new amount to the gram before it takes the difference', async () => {
|
||||
await service.update('batch-1', { amount: 22.5004 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({ deltaKg: 2.5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers 409 for a batch with an initial amount but no rest', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
initialAmount: 25,
|
||||
amount: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('batch-1', { amount: 22 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('books nothing when the amount stays the same', async () => {
|
||||
await service.update('batch-1', { amount: 20 }, 'user-1');
|
||||
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the current rest under the batch lock, then books', async () => {
|
||||
await service.update('batch-1', { amount: 22 }, 'user-1');
|
||||
|
||||
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
|
||||
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
reads[reads.length - 1],
|
||||
);
|
||||
expect(reads[reads.length - 1]).toBeLessThan(
|
||||
ledger.bookIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the first amount of a batch that had none without a booking', async () => {
|
||||
const untracked = { ...honeyBatch, initialAmount: null, amount: null };
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(untracked);
|
||||
|
||||
await service.update('batch-1', { amount: 12 }, 'user-1');
|
||||
|
||||
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 12);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('amount null goes to the ledger, which refuses it once bookings exist', async () => {
|
||||
await service.update('batch-1', { amount: null as never }, 'user-1');
|
||||
|
||||
expect(ledger.clearIn).toHaveBeenCalledWith(prisma, 'batch-1');
|
||||
expect(prisma.honeyBatch.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the 400 of the ledger on', async () => {
|
||||
ledger.clearIn.mockRejectedValue(new BadRequestException('x'));
|
||||
|
||||
await expect(
|
||||
service.update('batch-1', { amount: null as never }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMovement', () => {
|
||||
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
|
||||
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
|
||||
ledger.movementView.mockResolvedValue({
|
||||
id: 'm1',
|
||||
type: 'WITHDRAWAL',
|
||||
quantityGrams: -1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('books a withdrawal with its reason, with audit and event in the transaction', async () => {
|
||||
const result = await service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: -1, reason: 'EIGENBEDARF' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
|
||||
honeyBatchId: 'batch-1',
|
||||
deltaKg: -1,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'EIGENBEDARF',
|
||||
notes: null,
|
||||
actingUserId: 'user-1',
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'HoneyBatch',
|
||||
entityId: 'batch-1',
|
||||
action: 'UPDATE',
|
||||
}),
|
||||
prisma,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({
|
||||
type: 'HoneyBatchCorrected',
|
||||
aggregateId: 'batch-1',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ id: 'm1', quantityGrams: -1000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a withdrawal needs a reason and a negative amount', async () => {
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: -1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('Bitte einen Grund angeben.'));
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Eine Entnahme braucht eine negative Menge.'),
|
||||
);
|
||||
expect(ledger.bookIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a correction may carry a reason and stores it', async () => {
|
||||
await service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'CORRECTION', deltaKg: 1, reason: 'SONSTIGES' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(ledger.bookIn).toHaveBeenCalledWith(
|
||||
prisma,
|
||||
expect.objectContaining({ type: 'CORRECTION', reason: 'SONSTIGES' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a batch whose amount is not recorded', async () => {
|
||||
ledger.bookIn.mockResolvedValue({ amountKg: null, movementId: null });
|
||||
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'batch-1',
|
||||
{ type: 'CORRECTION', deltaKg: 1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.',
|
||||
),
|
||||
);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown batch', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createMovement(
|
||||
'x',
|
||||
{ type: 'CORRECTION', deltaKg: 1 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefill', () => {
|
||||
it('proposes the rest of the extraction with its honey type and water content', async () => {
|
||||
prisma.extraction.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -15,8 +15,14 @@ import {
|
||||
withWarnings,
|
||||
type PlausibilityWarning,
|
||||
} from '../common/warnings/plausibility-warning';
|
||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { HoneyBatchStatus, type Prisma } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
|
||||
import {
|
||||
HoneyBatchLedgerService,
|
||||
roundToGram,
|
||||
type HoneyBatchMovementView,
|
||||
} from './honey-batch-ledger.service';
|
||||
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
|
||||
@@ -59,6 +65,7 @@ export class HoneyBatchesService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly ledger: HoneyBatchLedgerService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: HoneyBatchFilter = {}) {
|
||||
@@ -82,19 +89,14 @@ export class HoneyBatchesService {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* without initialAmount (its amount was never recorded) does not count.
|
||||
*/
|
||||
private async batchesOf(db: DbClient, extractionId: string) {
|
||||
const batches = await db.honeyBatch.findMany({
|
||||
return db.honeyBatch.findMany({
|
||||
where: { extractionId },
|
||||
select: { id: true, initialAmount: true, amount: true },
|
||||
select: { id: true, initialAmount: 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. */
|
||||
@@ -127,8 +129,18 @@ export class HoneyBatchesService {
|
||||
// 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);
|
||||
// The amount never goes to Prisma directly: the ledger sets it together
|
||||
// with the initial amount.
|
||||
const { amount, ...rest } = dto;
|
||||
const honeyBatch = await tx.honeyBatch.create({
|
||||
data: { ...dto, batchNumber },
|
||||
data: { ...rest, batchNumber },
|
||||
});
|
||||
if (typeof amount === 'number') {
|
||||
await this.ledger.setInitialIn(tx, honeyBatch.id, amount);
|
||||
}
|
||||
const created = await tx.honeyBatch.findUniqueOrThrow({
|
||||
where: { id: honeyBatch.id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
@@ -136,7 +148,7 @@ export class HoneyBatchesService {
|
||||
entityId: honeyBatch.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: honeyBatch,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -147,10 +159,6 @@ export class HoneyBatchesService {
|
||||
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),
|
||||
@@ -198,10 +206,16 @@ export class HoneyBatchesService {
|
||||
? dto.storageContainerId
|
||||
: null;
|
||||
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
const { amount, ...rest } = dto;
|
||||
if (amount !== undefined) {
|
||||
await this.changeAmount(tx, before, amount, actingUserId);
|
||||
}
|
||||
if (Object.keys(rest).length > 0) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { ...rest },
|
||||
});
|
||||
}
|
||||
// Correcting the container has to leave the same trace a deliberate
|
||||
// relocation does - otherwise the batch's storage history would skip a
|
||||
// container it was recorded in.
|
||||
@@ -245,6 +259,114 @@ export class HoneyBatchesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PATCH of the amount, as a booking (spec 7.7). `before` was read under
|
||||
* the batch lock, so the difference is taken against the real rest. From
|
||||
* "not recorded" to a value sets the initial amount; with a recorded amount
|
||||
* the difference is a CORRECTION; null takes the amount away, which the
|
||||
* ledger refuses once bookings exist.
|
||||
*/
|
||||
private async changeAmount(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: { id: string; amount: number | null; initialAmount: number | null },
|
||||
amount: number | null,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
if (amount === null) {
|
||||
await this.ledger.clearIn(tx, before.id);
|
||||
return;
|
||||
}
|
||||
if (before.initialAmount === null) {
|
||||
await this.ledger.setInitialIn(tx, before.id, amount);
|
||||
return;
|
||||
}
|
||||
if (before.amount === null) {
|
||||
throw new ConflictException(
|
||||
'Die Menge der Honigcharge ist uneinheitlich gespeichert. Bitte die Charge neu laden oder den Support fragen.',
|
||||
);
|
||||
}
|
||||
const deltaKg = roundKg(roundToGram(amount) - before.amount);
|
||||
if (deltaKg === 0) {
|
||||
return;
|
||||
}
|
||||
await this.ledger.bookIn(tx, {
|
||||
honeyBatchId: before.id,
|
||||
deltaKg,
|
||||
type: 'CORRECTION',
|
||||
actingUserId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A manual booking in the movement book: a withdrawal (loose goods, sample,
|
||||
* breakage) or a correction after weighing again. Bottlings book
|
||||
* themselves. Booking, audit entry and event commit together.
|
||||
*/
|
||||
async createMovement(
|
||||
id: string,
|
||||
dto: CreateHoneyBatchMovementDto,
|
||||
actingUserId: string,
|
||||
): Promise<HoneyBatchMovementView> {
|
||||
if (dto.type === 'WITHDRAWAL') {
|
||||
if (dto.reason === undefined) {
|
||||
throw new BadRequestException('Bitte einen Grund angeben.');
|
||||
}
|
||||
if (dto.deltaKg > 0) {
|
||||
throw new BadRequestException(
|
||||
'Eine Entnahme braucht eine negative Menge.',
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowForUpdate(tx, 'HoneyBatch', id);
|
||||
const before = await tx.honeyBatch.findUnique({
|
||||
where: { id },
|
||||
include: honeyBatchInclude,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
const booked = await this.ledger.bookIn(tx, {
|
||||
honeyBatchId: id,
|
||||
deltaKg: dto.deltaKg,
|
||||
type: dto.type,
|
||||
reason: dto.reason ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
actingUserId,
|
||||
});
|
||||
if (booked.movementId === null) {
|
||||
throw new BadRequestException(
|
||||
booked.amountKg === null
|
||||
? 'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.'
|
||||
: 'Die Buchung ist kleiner als ein Gramm.',
|
||||
);
|
||||
}
|
||||
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 this.ledger.movementView(tx, booked.movementId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in new issue
Block a user