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

@@ -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({