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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user