feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { ImportService } from './import.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('ImportService', () => {
let service: ImportService;
@@ -9,19 +10,31 @@ describe('ImportService', () => {
apiary: { create: jest.Mock; findFirst: jest.Mock };
colony: { create: jest.Mock; findUnique: jest.Mock };
queen: { create: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let tx: { colony: { create: jest.Mock } };
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
beforeEach(() => {
prisma = {
apiary: { create: jest.fn(), findFirst: jest.fn() },
colony: { create: jest.fn(), findUnique: jest.fn() },
queen: { create: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn(),
};
// The colony row is written through the transaction client; it shares the
// mock with prisma.colony so the row tests read the same calls.
tx = { colony: { create: prisma.colony.create } };
prisma.$transaction.mockImplementation(
(callback: (txArg: typeof tx) => unknown) => callback(tx),
);
auditLog = { record: jest.fn() };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ImportService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
@@ -195,6 +208,56 @@ describe('ImportService', () => {
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
});
it('records audit and ColonyCreated in the transaction of every created colony', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create
.mockResolvedValueOnce({ id: 'colony-1', apiaryId: 'apiary-1' })
.mockResolvedValueOnce({ id: 'colony-2', apiaryId: 'apiary-1' });
const report = await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,PRODUCTION_COLONY',
),
'user-1',
);
expect(report.created).toBe(2);
expect(events.record).toHaveBeenCalledTimes(2);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Colony',
entityId: 'colony-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
});
it('records no event for a skipped or invalid row', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,NOT_A_TYPE',
),
'user-1',
);
expect(events.record).not.toHaveBeenCalled();
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
+25 -3
View File
@@ -3,6 +3,8 @@ import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { colonyCreatedEvent } from '../colonies/colony-created-event';
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
@@ -56,6 +58,7 @@ export class ImportService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async importCsv(
@@ -80,7 +83,9 @@ export class ImportService {
record[column] = (dataRows[i][index] ?? '').trim();
});
const rowNumber = i + 1;
results.push(await this.importRow(entity, record, rowNumber));
results.push(
await this.importRow(entity, record, rowNumber, actingUserId),
);
}
const report: ImportReport = {
@@ -110,12 +115,13 @@ export class ImportService {
entity: ImportEntity,
record: Record<string, string>,
rowNumber: number,
actingUserId: string,
): Promise<ImportRowResult> {
switch (entity) {
case 'apiaries':
return this.importApiaryRow(record, rowNumber);
case 'colonies':
return this.importColonyRow(record, rowNumber);
return this.importColonyRow(record, rowNumber, actingUserId);
case 'queens':
return this.importQueenRow(record, rowNumber);
}
@@ -150,6 +156,7 @@ export class ImportService {
private async importColonyRow(
record: Record<string, string>,
rowNumber: number,
actingUserId: string,
): Promise<ImportRowResult> {
const apiaryName = emptyToUndefined(record.apiaryName);
if (!apiaryName) {
@@ -209,7 +216,22 @@ export class ImportService {
id: existing.id,
};
}
const colony = await this.prisma.colony.create({ data: { ...dto } });
// Like ColoniesService.create: record, audit and event in one transaction.
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
return created;
});
return {
row: rowNumber,
status: 'created',