feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- 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:
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' });
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user