feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -12,7 +12,12 @@ describe('ImportService', () => {
|
||||
queen: { create: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: { colony: { create: jest.Mock } };
|
||||
let tx: {
|
||||
colony: { create: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
@@ -25,7 +30,16 @@ describe('ImportService', () => {
|
||||
};
|
||||
// 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 } };
|
||||
tx = {
|
||||
colony: { create: prisma.colony.create },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
apiary: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'apiary-1', status: 'ACTIVE' }),
|
||||
},
|
||||
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation(
|
||||
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||
);
|
||||
@@ -243,6 +257,84 @@ describe('ImportService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the apiary (share) and checks activity and the restricted zone before it writes', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
prisma.colony.create.mockResolvedValue({
|
||||
id: 'c1',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
|
||||
await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
|
||||
expect(query.sql).toContain('"Apiary"');
|
||||
expect(query.sql).toContain('FOR SHARE');
|
||||
expect(tx.apiaryRestriction.findMany).toHaveBeenCalled();
|
||||
expect(
|
||||
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(prisma.colony.create.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('reports a row in a restricted apiary as an error and writes nothing for it', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
tx.apiaryRestriction.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'apiary-1',
|
||||
apiary: { name: 'Teststand' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: new Date('2000-01-01T00:00:00.000Z'),
|
||||
endsOn: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const report = await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(report.errors).toBe(1);
|
||||
expect(report.rows[0].status).toBe('error');
|
||||
expect(report.rows[0].message).toContain('Sperrbezirk');
|
||||
expect(prisma.colony.create).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Colony' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a row in an apiary deactivated meanwhile as an error', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
tx.apiary.findUnique.mockResolvedValue({
|
||||
id: 'apiary-1',
|
||||
status: 'INACTIVE',
|
||||
});
|
||||
|
||||
const report = await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(report.rows[0].status).toBe('error');
|
||||
expect(prisma.colony.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records no event for a skipped or invalid row', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidationError } from 'class-validator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -12,6 +16,9 @@ import {
|
||||
appendLegacyFeedNote,
|
||||
feedConditionFromInput,
|
||||
} from '../common/feed-vocabulary';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { assertNoActiveRestriction } from '../compliance/restricted-zone';
|
||||
import { lockRowsForShare } from '../prisma/row-lock';
|
||||
import { parseCsv } from './csv';
|
||||
|
||||
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
|
||||
@@ -216,8 +223,26 @@ export class ImportService {
|
||||
id: existing.id,
|
||||
};
|
||||
}
|
||||
// Like ColoniesService.create: record, audit and event in one transaction.
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
// Like ColoniesService.create: apiary rows locked (share), activity and
|
||||
// restricted zone checked under the lock - an import has no override -,
|
||||
// then record, audit and event in one transaction.
|
||||
const outcome = await this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForShare(tx, 'Apiary', [apiary.id]);
|
||||
const locked = await tx.apiary.findUnique({ where: { id: apiary.id } });
|
||||
if (locked?.status !== 'ACTIVE') {
|
||||
return {
|
||||
error: `Bienenstand "${apiaryName}" ist nicht aktiv und kann keine Völker aufnehmen`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await assertNoActiveRestriction(tx, [apiary.id], berlinToday());
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictException) {
|
||||
const body = error.getResponse() as { message?: string };
|
||||
return { error: body.message ?? error.message };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const created = await tx.colony.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
@@ -230,8 +255,12 @@ export class ImportService {
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
return created;
|
||||
return { colony: created };
|
||||
});
|
||||
if ('error' in outcome) {
|
||||
return { row: rowNumber, status: 'error', message: outcome.error };
|
||||
}
|
||||
const { colony } = outcome;
|
||||
return {
|
||||
row: rowNumber,
|
||||
status: 'created',
|
||||
|
||||
Reference in new issue
Block a user