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
@@ -0,0 +1,236 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { ComplianceSettingsService } from './compliance-settings.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const STORED = {
|
||||
id: 'singleton',
|
||||
veterinaryOfficeName: null,
|
||||
veterinaryOfficeAddress: null,
|
||||
registrationNumber: null,
|
||||
noticeOnColonyCountChange: true,
|
||||
noticeOnApiaryChange: true,
|
||||
tskReferenceDay: '01-01',
|
||||
tskReportDueDay: null,
|
||||
tskMemberNumber: null,
|
||||
tskExcludedColonyTypes: [],
|
||||
netQuantityDigitRules: [
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
describe('ComplianceSettingsService', () => {
|
||||
let service: ComplianceSettingsService;
|
||||
let tx: {
|
||||
complianceSettings: {
|
||||
upsert: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
complianceSettings: { findUnique: jest.Mock; upsert: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
tx = {
|
||||
complianceSettings: {
|
||||
upsert: jest.fn().mockResolvedValue(STORED),
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue(STORED),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve({ ...STORED, ...data }),
|
||||
),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
upsert: jest.fn().mockResolvedValue(STORED),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new ComplianceSettingsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates defaults on first read', async () => {
|
||||
const settings = await service.get();
|
||||
|
||||
expect(prisma.complianceSettings.upsert).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton' },
|
||||
});
|
||||
expect(settings).toBe(STORED);
|
||||
});
|
||||
|
||||
it('returns the stored row without creating one', async () => {
|
||||
prisma.complianceSettings.findUnique.mockResolvedValue(STORED);
|
||||
|
||||
await service.get();
|
||||
|
||||
expect(prisma.complianceSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects 02-29 as reference day with 400', async () => {
|
||||
await expect(
|
||||
service.update({ tskReferenceDay: '02-29' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.update({ tskReportDueDay: '13-01' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects digit rules that are not ascending or whose last rule has a limit', async () => {
|
||||
const attempts = [
|
||||
[
|
||||
{ maxGrams: 200, minMm: 3 },
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
[
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: 50, minMm: 3 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
[{ maxGrams: 50, minMm: 2 }],
|
||||
[
|
||||
{ maxGrams: null, minMm: 2 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
[],
|
||||
];
|
||||
for (const rules of attempts) {
|
||||
await expect(
|
||||
service.update({ netQuantityDigitRules: rules }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a digit height outside 0.5 to 20 mm and more than 10 rules', async () => {
|
||||
await expect(
|
||||
service.update(
|
||||
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 0.4 }] },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.update(
|
||||
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 20.5 }] },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
const eleven = Array.from({ length: 10 }, (_, index) => ({
|
||||
maxGrams: (index + 1) * 10,
|
||||
minMm: 2,
|
||||
})).concat([{ maxGrams: null as never, minMm: 2 }]);
|
||||
await expect(
|
||||
service.update({ netQuantityDigitRules: eleven }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('treats a missing maxGrams key like null', async () => {
|
||||
await service.update(
|
||||
{
|
||||
netQuantityDigitRules: [
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ minMm: 6 } as never,
|
||||
],
|
||||
},
|
||||
'u1',
|
||||
);
|
||||
|
||||
const call = (
|
||||
tx.complianceSettings.update.mock.calls as unknown[][]
|
||||
)[0][0] as {
|
||||
data: { netQuantityDigitRules: unknown };
|
||||
};
|
||||
expect(call.data.netQuantityDigitRules).toEqual([
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
]);
|
||||
await expect(
|
||||
service.update(
|
||||
{
|
||||
netQuantityDigitRules: [{ minMm: 2 } as never, { minMm: 6 } as never],
|
||||
},
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a colony type listed twice', async () => {
|
||||
await expect(
|
||||
service.update(
|
||||
{ tskExcludedColonyTypes: ['NUCLEUS_COLONY', 'NUCLEUS_COLONY'] },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('stores valid values under the row lock with an audit entry', async () => {
|
||||
await service.update(
|
||||
{
|
||||
veterinaryOfficeName: ' Veterinäramt ',
|
||||
tskReferenceDay: '04-15',
|
||||
tskReportDueDay: null,
|
||||
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
|
||||
},
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'ComplianceSettings',
|
||||
'singleton',
|
||||
);
|
||||
expect(tx.complianceSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: expect.objectContaining({
|
||||
veterinaryOfficeName: 'Veterinäramt',
|
||||
tskReferenceDay: '04-15',
|
||||
tskReportDueDay: null,
|
||||
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
|
||||
}) as unknown,
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ComplianceSettings',
|
||||
entityId: 'singleton',
|
||||
action: 'UPDATE',
|
||||
userId: 'u1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('an omitted field keeps the stored value', async () => {
|
||||
await service.update({ noticeOnApiaryChange: false }, 'u1');
|
||||
|
||||
const call = (
|
||||
tx.complianceSettings.update.mock.calls as unknown[][]
|
||||
)[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(call.data.noticeOnApiaryChange).toBe(false);
|
||||
expect(call.data.tskReferenceDay).toBeUndefined();
|
||||
expect(call.data.veterinaryOfficeName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user