feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 21:13:23 +02:00
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