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,22 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateColonyDto } from '../colonies/dto/update-colony.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateApiaryRestrictionDto: {
|
||||
dto: UpdateApiaryRestrictionDto,
|
||||
notNull: ['disease', 'startsOn'],
|
||||
clearable: ['endsOn', 'authority', 'reference', 'notes'],
|
||||
},
|
||||
// The reason is only read, never stored: null means "no override".
|
||||
UpdateColonyDto: {
|
||||
dto: UpdateColonyDto,
|
||||
notNull: ['status'],
|
||||
clearable: ['restrictionOverrideReason'],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Apiary restriction DTOs', contracts);
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
import { CreateApiaryRestrictionDto } from './dto/create-apiary-restriction.dto';
|
||||
import { RestrictionListQuery } from './dto/restriction-list-query.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
// Full paths on an empty prefix: the list lives under /apiary-restrictions,
|
||||
// the per-apiary read under /apiaries/:id/restrictions.
|
||||
@ApiTags('apiary-restrictions')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class ApiaryRestrictionsController {
|
||||
constructor(
|
||||
private readonly restrictions: ApiaryRestrictionsService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
// Authority, file reference and notes are for compliance:read only.
|
||||
private async mayReadDetails(actor: AuthenticatedUser): Promise<boolean> {
|
||||
return (await this.permissions.getPermissions(actor.id)).has(
|
||||
'compliance:read',
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get('apiary-restrictions')
|
||||
async findAll(
|
||||
@Query() query: RestrictionListQuery,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.list(
|
||||
query.active,
|
||||
await this.mayReadDetails(actor),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get('apiaries/:id/restrictions')
|
||||
async findForApiary(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.findForApiary(
|
||||
id,
|
||||
await this.mayReadDetails(actor),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Idempotent()
|
||||
@Post('apiary-restrictions')
|
||||
create(
|
||||
@Body() dto: CreateApiaryRestrictionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Patch('apiary-restrictions/:id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateApiaryRestrictionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.update(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiaryRestrictionsController } from './apiary-restrictions.controller';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
|
||||
/**
|
||||
* Not needed in WorkerModule: the service injects no permission resolver and
|
||||
* the guard helper (compliance/restricted-zone.ts) is a plain function.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ApiaryRestrictionsController],
|
||||
providers: [ApiaryRestrictionsService],
|
||||
exports: [ApiaryRestrictionsService],
|
||||
})
|
||||
export class ApiaryRestrictionsModule {}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
|
||||
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
describe('ApiaryRestrictionsService', () => {
|
||||
let service: ApiaryRestrictionsService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findMany: jest.Mock };
|
||||
apiaryRestriction: {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
apiary: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
apiary: { findMany: jest.fn() },
|
||||
apiaryRestriction: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
apiary: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiaryRestrictionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
function lockStatements(): string[] {
|
||||
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
|
||||
([query]) => query.sql,
|
||||
);
|
||||
}
|
||||
|
||||
describe('create', () => {
|
||||
const dto = {
|
||||
apiaryIds: ['a2', 'a1'],
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2026-10-01',
|
||||
};
|
||||
|
||||
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||
tx.apiaryRestriction.create.mockImplementation(
|
||||
({ data }: { data: { apiaryId: string } }) =>
|
||||
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
|
||||
);
|
||||
|
||||
const created = await service.create(dto, 'user-1');
|
||||
|
||||
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockStatements()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
]);
|
||||
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
expect(firstRow.data).toMatchObject({
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2026-10-01'),
|
||||
createdByUserId: 'user-1',
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(2);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ApiaryRestriction',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||
|
||||
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
|
||||
await expect(
|
||||
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects no apiary and more than 50 apiaries with 400', async () => {
|
||||
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
|
||||
|
||||
await expect(
|
||||
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const stored = {
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: null as Date | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||
tx.apiaryRestriction.update.mockImplementation(
|
||||
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
|
||||
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
|
||||
|
||||
expect(lockStatements()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
|
||||
]);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
|
||||
const result = await service.update(
|
||||
'r1',
|
||||
{ endsOn: '2026-10-20' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { endsOn: day('2026-10-20') },
|
||||
});
|
||||
expect(result.endsOn).toEqual(day('2026-10-20'));
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ApiaryRestriction',
|
||||
entityId: 'r1',
|
||||
action: 'UPDATE',
|
||||
beforeData: stored,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('null clears endsOn', async () => {
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||
...stored,
|
||||
endsOn: day('2026-10-20'),
|
||||
});
|
||||
|
||||
await service.update('r1', { endsOn: null }, 'user-1');
|
||||
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { endsOn: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('checks endsOn >= startsOn on the merged state (400)', async () => {
|
||||
await expect(
|
||||
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||
...stored,
|
||||
endsOn: day('2026-10-20'),
|
||||
});
|
||||
await expect(
|
||||
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown restriction without locking', async () => {
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('findActive lists only restrictions active today, with the apiary name', async () => {
|
||||
prisma.apiaryRestriction.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
apiary: { name: 'Nord' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2000-01-01'),
|
||||
endsOn: null,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
apiaryId: 'a2',
|
||||
apiary: { name: 'Sued' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2000-01-01'),
|
||||
endsOn: day('2000-01-02'),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.findActive()).resolves.toEqual([
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Nord',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2000-01-01',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
|
||||
const row = {
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
authority: 'Veterinaeramt',
|
||||
reference: 'AZ 12/3',
|
||||
notes: 'intern',
|
||||
createdByUserId: 'u1',
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: null,
|
||||
};
|
||||
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
|
||||
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
|
||||
const expected = [
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2026-10-01',
|
||||
endsOn: null,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(service.list(undefined, false)).resolves.toEqual(expected);
|
||||
await expect(service.findForApiary('a1', false)).resolves.toEqual(
|
||||
expected,
|
||||
);
|
||||
await expect(service.list(undefined, true)).resolves.toEqual([row]);
|
||||
});
|
||||
|
||||
it('findForApiary answers 404 for an unknown apiary', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findForApiary('missing')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiaryRestriction } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import {
|
||||
ActiveRestrictionInfo,
|
||||
isRestrictionActive,
|
||||
startedRestrictions,
|
||||
toActiveRestrictionInfo,
|
||||
} from '../compliance/restricted-zone';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
CreateApiaryRestrictionDto,
|
||||
MAX_RESTRICTION_APIARIES,
|
||||
} from './dto/create-apiary-restriction.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
const AUDIT_ENTITY = 'ApiaryRestriction';
|
||||
|
||||
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
|
||||
export interface RestrictionSummary {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
disease: string;
|
||||
startsOn: string;
|
||||
endsOn: string | null;
|
||||
}
|
||||
|
||||
function toSummary(row: ApiaryRestriction): RestrictionSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
apiaryId: row.apiaryId,
|
||||
disease: row.disease,
|
||||
startsOn: row.startsOn.toISOString().slice(0, 10),
|
||||
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
|
||||
if (endsOn !== null && endsOn < startsOn) {
|
||||
throw new BadRequestException(
|
||||
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
|
||||
* it ends by an endsOn date, so the colony history stays explainable.
|
||||
*
|
||||
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
|
||||
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
|
||||
* restriction and a move into that apiary queue up behind each other.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiaryRestrictionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
|
||||
async create(
|
||||
dto: CreateApiaryRestrictionDto,
|
||||
actingUserId: string,
|
||||
): Promise<ApiaryRestriction[]> {
|
||||
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
|
||||
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
|
||||
throw new BadRequestException(
|
||||
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
|
||||
);
|
||||
}
|
||||
const startsOn = dayToDate(dto.startsOn);
|
||||
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
|
||||
assertEndNotBeforeStart(startsOn, endsOn);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
|
||||
const known = await tx.apiary.findMany({
|
||||
where: { id: { in: apiaryIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== apiaryIds.length) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
const created: ApiaryRestriction[] = [];
|
||||
for (const apiaryId of apiaryIds) {
|
||||
const restriction = await tx.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: dto.disease,
|
||||
authority: dto.authority,
|
||||
reference: dto.reference,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notes: dto.notes,
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: restriction.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: restriction,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
created.push(restriction);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateApiaryRestrictionDto,
|
||||
actingUserId: string,
|
||||
): Promise<ApiaryRestriction> {
|
||||
const seen = await this.prisma.apiaryRestriction.findUnique({
|
||||
where: { id },
|
||||
select: { apiaryId: true },
|
||||
});
|
||||
if (!seen) {
|
||||
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
|
||||
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
|
||||
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||
}
|
||||
const startsOnPatch =
|
||||
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
|
||||
const startsOn = startsOnPatch ?? before.startsOn;
|
||||
const endsOn = optionalDate(dto.endsOn);
|
||||
assertEndNotBeforeStart(
|
||||
startsOn,
|
||||
endsOn === undefined ? before.endsOn : endsOn,
|
||||
);
|
||||
const updated = await tx.apiaryRestriction.update({
|
||||
where: { id },
|
||||
data: {
|
||||
disease: dto.disease,
|
||||
authority: dto.authority,
|
||||
reference: dto.reference,
|
||||
startsOn: startsOnPatch,
|
||||
endsOn,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** The restrictions in force today (Berlin day), with the apiary name. */
|
||||
async findActive(): Promise<ActiveRestrictionInfo[]> {
|
||||
const today = berlinToday();
|
||||
const started = await startedRestrictions(this.prisma, undefined, today);
|
||||
return started
|
||||
.filter((row) => isRestrictionActive(row, today))
|
||||
.map(toActiveRestrictionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every restriction (newest first); `active` narrows to in force / ended.
|
||||
* Authority, file reference, notes and the creator are for callers with
|
||||
* compliance:read (`detailed`); everybody else gets the summary.
|
||||
*/
|
||||
async list(
|
||||
active?: boolean,
|
||||
detailed = true,
|
||||
): Promise<
|
||||
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
|
||||
> {
|
||||
if (active === true) {
|
||||
return this.findActive();
|
||||
}
|
||||
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const wanted =
|
||||
active === false
|
||||
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
|
||||
: rows;
|
||||
return detailed ? wanted : wanted.map(toSummary);
|
||||
}
|
||||
|
||||
async findForApiary(
|
||||
apiaryId: string,
|
||||
detailed = true,
|
||||
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
|
||||
const apiary = await this.prisma.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||
where: { apiaryId },
|
||||
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
return detailed ? rows : rows.map(toSummary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MAX_RESTRICTION_APIARIES = 50;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const MAX_TEXT_LENGTH = 200;
|
||||
const MAX_NOTE_LENGTH = 2000;
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||
|
||||
/** A restricted zone declared by the authority, for one or several apiaries. */
|
||||
export class CreateApiaryRestrictionDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: 'Ein Datensatz je Stand, 1 bis 50 Stände',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_RESTRICTION_APIARIES)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
@Matches(ID_PATTERN, { each: true })
|
||||
@MaxLength(MAX_ID_LENGTH, { each: true })
|
||||
apiaryIds!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Seuche, z. B. Amerikanische Faulbrut' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'disease darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
disease!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Anordnende Behörde' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
authority?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
reference?: string | null;
|
||||
|
||||
@ApiProperty({ example: '2026-10-01', description: 'Erster Tag der Sperre' })
|
||||
@Matches(DAY_PATTERN, { message: `startsOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
startsOn!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
example: '2026-12-01',
|
||||
description: 'Letzter Tag der Sperre; leer = bis auf Weiteres',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `endsOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
endsOn?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { booleanQuery } from '../../veterinary-medicines/dto/medicine-list-query.dto';
|
||||
|
||||
export class RestrictionListQuery {
|
||||
@ApiPropertyOptional({
|
||||
description: 'true: nur heute geltende Sperren; false: nur beendete',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateApiaryRestrictionDto } from './create-apiary-restriction.dto';
|
||||
|
||||
/**
|
||||
* The apiary of a restriction is fixed: a restriction for another stand is a
|
||||
* new restriction. Omitted stays, null clears a nullable field (endsOn ends
|
||||
* the restriction as open again), null on disease or startsOn is a 400.
|
||||
*/
|
||||
export class UpdateApiaryRestrictionDto extends PartialType(
|
||||
OmitType(CreateApiaryRestrictionDto, ['apiaryIds'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -61,6 +61,7 @@ import { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { ComplianceModule } from './compliance/compliance.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
@@ -134,6 +135,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
ComplianceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
|
||||
import { ColoniesController } from './colonies.controller';
|
||||
import { ColoniesService } from './colonies.service';
|
||||
|
||||
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
|
||||
// colony paths that enforce them (AppModule imports ColoniesModule).
|
||||
@Module({
|
||||
imports: [ApiaryRestrictionsModule],
|
||||
controllers: [ColoniesController],
|
||||
providers: [ColoniesService],
|
||||
exports: [ColoniesService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||
|
||||
@@ -16,6 +17,8 @@ describe('ColoniesService', () => {
|
||||
colonyLineage: { create: jest.Mock };
|
||||
queenColonyHistory: { updateMany: jest.Mock };
|
||||
suggestion: { updateMany: jest.Mock };
|
||||
apiary: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findMany: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
colony: {
|
||||
@@ -32,6 +35,7 @@ describe('ColoniesService', () => {
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock; recordAll: jest.Mock };
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
|
||||
const parentColony = {
|
||||
id: 'colony-1',
|
||||
@@ -49,10 +53,12 @@ describe('ColoniesService', () => {
|
||||
colonyLineage: { create: jest.fn() },
|
||||
queenColonyHistory: { updateMany: jest.fn() },
|
||||
suggestion: { updateMany: jest.fn() },
|
||||
apiary: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
prisma = {
|
||||
colony: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
@@ -69,6 +75,10 @@ describe('ColoniesService', () => {
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
// The apiary is read inside the transaction now: same answers as before.
|
||||
tx.apiary.findUnique.mockImplementation(
|
||||
(args: unknown) => prisma.apiary.findUnique(args) as Promise<unknown>,
|
||||
);
|
||||
// The locked re-read sees what the pre-read saw unless a test says otherwise.
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
|
||||
@@ -79,11 +89,17 @@ describe('ColoniesService', () => {
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
recordAll: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
resolver = {
|
||||
getPermissions: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Set(['compliance:manage'])),
|
||||
};
|
||||
service = new ColoniesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
events as unknown as DomainEventsService,
|
||||
resolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -237,17 +253,23 @@ describe('ColoniesService', () => {
|
||||
});
|
||||
|
||||
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
||||
prisma.colony.findUnique
|
||||
.mockResolvedValueOnce(parentColony)
|
||||
.mockResolvedValueOnce({
|
||||
...parentColony,
|
||||
id: 'colony-1',
|
||||
});
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
id: 'colony-3',
|
||||
status: 'ACTIVE',
|
||||
currentQueenId: 'queen-2',
|
||||
});
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
where.id === 'colony-1'
|
||||
? parentColony
|
||||
: {
|
||||
id: 'colony-3',
|
||||
apiaryId: 'apiary-1',
|
||||
status: 'ACTIVE',
|
||||
currentQueenId: 'queen-2',
|
||||
},
|
||||
),
|
||||
);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
id: 'colony-3',
|
||||
status: 'MERGED',
|
||||
@@ -725,10 +747,15 @@ describe('ColoniesService', () => {
|
||||
|
||||
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-4', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({
|
||||
id: where.id,
|
||||
apiaryId: 'apiary-1',
|
||||
status: 'ACTIVE',
|
||||
healthStatus: null,
|
||||
currentQueenId: null,
|
||||
@@ -809,7 +836,8 @@ describe('ColoniesService', () => {
|
||||
});
|
||||
await service.reactivate('colony-1', 'user-1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
// update 1, move 2 (apiary share + colony), dissolve 1, reactivate 1.
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
|
||||
@@ -860,9 +888,18 @@ describe('ColoniesService', () => {
|
||||
|
||||
it('locks every merged colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-3', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-4', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
|
||||
Promise.resolve({
|
||||
id: where.id,
|
||||
apiaryId: 'apiary-1',
|
||||
status: 'ACTIVE',
|
||||
healthStatus: null,
|
||||
}),
|
||||
);
|
||||
tx.colony.update.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
@@ -875,10 +912,12 @@ describe('ColoniesService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// One multi-row statement, not one per colony.
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
|
||||
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
|
||||
// The apiary rows (share), then ONE multi-row statement for the colonies.
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
const [, [query]] = tx.$queryRaw.mock.calls as [unknown, [{ sql: string }]];
|
||||
expect(query.sql).toContain(
|
||||
'"Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
);
|
||||
});
|
||||
|
||||
describe('a colony that ends through an edit', () => {
|
||||
@@ -932,4 +971,521 @@ describe('ColoniesService', () => {
|
||||
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restricted zone', () => {
|
||||
const reason = 'Amtstierarzt hat die Verbringung genehmigt';
|
||||
const reloadMessage = 'Der Stand wurde gerade geändert. Bitte neu laden.';
|
||||
const startedLongAgo = new Date('2000-01-01T00:00:00.000Z');
|
||||
|
||||
// An open restriction on each of the given apiaries.
|
||||
function restrict(...apiaryIds: string[]): void {
|
||||
tx.apiaryRestriction.findMany.mockImplementation(
|
||||
({ where }: { where: { apiaryId: { in: string[] } } }) =>
|
||||
Promise.resolve(
|
||||
apiaryIds
|
||||
.filter((apiaryId) => where.apiaryId.in.includes(apiaryId))
|
||||
.map((apiaryId) => ({
|
||||
id: `r-${apiaryId}`,
|
||||
apiaryId,
|
||||
apiary: { name: `Stand ${apiaryId}` },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: startedLongAgo,
|
||||
endsOn: null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function lockSql(): string[] {
|
||||
return (tx.$queryRaw.mock.calls as [{ sql: string }][]).map(
|
||||
([query]) => query.sql,
|
||||
);
|
||||
}
|
||||
|
||||
function setupMove(): void {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
}
|
||||
|
||||
function setupMerge(mergedApiaryId = 'apiary-2'): void {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
prisma.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-3', apiaryId: mergedApiaryId },
|
||||
]);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
where.id === 'colony-1'
|
||||
? parentColony
|
||||
: {
|
||||
id: 'colony-3',
|
||||
apiaryId: mergedApiaryId,
|
||||
status: 'ACTIVE',
|
||||
currentQueenId: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
id: 'colony-3',
|
||||
status: 'MERGED',
|
||||
healthStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
const createDto = {
|
||||
code: 'V-9',
|
||||
apiaryId: 'apiary-1',
|
||||
colonyType: 'PRODUCTION_COLONY' as const,
|
||||
};
|
||||
|
||||
it('split inside the apiary is allowed', async () => {
|
||||
restrict('apiary-1');
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-2',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||
|
||||
await expect(
|
||||
service.split(
|
||||
'colony-1',
|
||||
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||
'user-1',
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create in a restricted apiary is refused', async () => {
|
||||
restrict('apiary-1');
|
||||
|
||||
await expect(service.create(createDto, 'user-1')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { code: 'RESTRICTED_ZONE' },
|
||||
});
|
||||
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['into', 'apiary-2'],
|
||||
['out of', 'apiary-1'],
|
||||
])('move %s a restricted apiary is refused', async (_way, restricted) => {
|
||||
setupMove();
|
||||
restrict(restricted);
|
||||
|
||||
await expect(
|
||||
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { code: 'RESTRICTED_ZONE' },
|
||||
});
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('move to the same apiary moves nothing and checks nothing', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
restrict('apiary-1');
|
||||
|
||||
await expect(
|
||||
service.move('colony-1', { apiaryId: 'apiary-1' }, 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the old apiary', 'apiary-1'],
|
||||
['the new apiary', 'apiary-2'],
|
||||
])('update with a changed apiary checks %s', async (_which, restricted) => {
|
||||
setupMove();
|
||||
restrict(restricted);
|
||||
|
||||
await expect(
|
||||
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { code: 'RESTRICTED_ZONE' },
|
||||
});
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a name change checks nothing and takes no apiary lock', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
restrict('apiary-1', 'apiary-2');
|
||||
|
||||
await service.update('colony-1', { code: 'V-neu' }, 'user-1');
|
||||
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
|
||||
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
expect(lockSql().every((sql) => !sql.includes('"Apiary"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('merge across apiaries is refused when one is restricted', async () => {
|
||||
setupMerge('apiary-2');
|
||||
restrict('apiary-2');
|
||||
|
||||
await expect(
|
||||
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
response: { code: 'RESTRICTED_ZONE' },
|
||||
});
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
expect(tx.colonyLineage.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merge within one apiary is allowed even in a restricted apiary', async () => {
|
||||
setupMerge('apiary-1');
|
||||
restrict('apiary-1');
|
||||
|
||||
await expect(
|
||||
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const scenarios: Record<
|
||||
string,
|
||||
{
|
||||
run: (restrictionOverrideReason: string) => Promise<unknown>;
|
||||
setup: () => void;
|
||||
restricted: string[];
|
||||
}
|
||||
> = {
|
||||
create: {
|
||||
setup: () => {
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-9',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
},
|
||||
restricted: ['apiary-1'],
|
||||
run: (restrictionOverrideReason) =>
|
||||
service.create({ ...createDto, restrictionOverrideReason }, 'user-1'),
|
||||
},
|
||||
update: {
|
||||
setup: setupMove,
|
||||
restricted: ['apiary-2'],
|
||||
run: (restrictionOverrideReason) =>
|
||||
service.update(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason },
|
||||
'user-1',
|
||||
),
|
||||
},
|
||||
move: {
|
||||
setup: setupMove,
|
||||
restricted: ['apiary-2'],
|
||||
run: (restrictionOverrideReason) =>
|
||||
service.move(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason },
|
||||
'user-1',
|
||||
),
|
||||
},
|
||||
merge: {
|
||||
setup: () => setupMerge('apiary-2'),
|
||||
restricted: ['apiary-2'],
|
||||
run: (restrictionOverrideReason) =>
|
||||
service.merge(
|
||||
'colony-1',
|
||||
{ mergedColonyIds: ['colony-3'], restrictionOverrideReason },
|
||||
'user-1',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
it.each(Object.keys(scenarios))(
|
||||
'the override reason is never part of the data written to the colony (%s)',
|
||||
async (name) => {
|
||||
const scenario = scenarios[name];
|
||||
scenario.setup();
|
||||
restrict(...scenario.restricted);
|
||||
|
||||
await scenario.run(reason);
|
||||
|
||||
const written = JSON.stringify([
|
||||
tx.colony.create.mock.calls,
|
||||
tx.colony.update.mock.calls,
|
||||
]);
|
||||
expect(written).not.toContain(reason);
|
||||
expect(written).not.toContain('restrictionOverrideReason');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(Object.keys(scenarios))(
|
||||
'an override writes the reason and the restriction ids into the audit entry (%s)',
|
||||
async (name) => {
|
||||
const scenario = scenarios[name];
|
||||
scenario.setup();
|
||||
restrict(...scenario.restricted);
|
||||
|
||||
await scenario.run(reason);
|
||||
|
||||
const audits = (
|
||||
auditLog.record.mock.calls as [
|
||||
{ afterData: { restrictionOverride?: unknown } },
|
||||
][]
|
||||
).filter(([entry]) => entry.afterData.restrictionOverride);
|
||||
expect(audits).toHaveLength(1);
|
||||
expect(audits[0][0].afterData.restrictionOverride).toEqual({
|
||||
reason,
|
||||
restrictionIds: [`r-${scenario.restricted[0]}`],
|
||||
});
|
||||
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('an override writes the reason into ColonyApiaryHistory.notes, in front of an existing note', async () => {
|
||||
setupMove();
|
||||
restrict('apiary-2');
|
||||
|
||||
await service.move(
|
||||
'colony-1',
|
||||
{
|
||||
apiaryId: 'apiary-2',
|
||||
notes: 'Wanderung',
|
||||
restrictionOverrideReason: reason,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
await service.update(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const notes = (
|
||||
tx.colonyApiaryHistory.create.mock.calls as [
|
||||
{ data: { notes: string } },
|
||||
][]
|
||||
).map(([call]) => call.data.notes);
|
||||
expect(notes).toEqual([
|
||||
`Ausnahme Sperrbezirk: ${reason}\nWanderung`,
|
||||
`Ausnahme Sperrbezirk: ${reason}\nKorrektur über Volk bearbeiten`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('an override without compliance:manage answers 403 and writes nothing', async () => {
|
||||
setupMove();
|
||||
restrict('apiary-2');
|
||||
resolver.getPermissions.mockResolvedValue(new Set(['colonies:update']));
|
||||
|
||||
await expect(
|
||||
service.move(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a reason without an active restriction is ignored for a user with the right: no note, no audit field', async () => {
|
||||
setupMove();
|
||||
|
||||
await service.move(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(
|
||||
JSON.stringify(tx.colonyApiaryHistory.create.mock.calls),
|
||||
).not.toContain('Ausnahme');
|
||||
expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain(
|
||||
'restrictionOverride',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(Object.keys(scenarios))(
|
||||
'a reason without compliance:manage answers 403 up front, even without any restriction (%s)',
|
||||
async (name) => {
|
||||
const scenario = scenarios[name];
|
||||
scenario.setup();
|
||||
resolver.getPermissions.mockResolvedValue(new Set());
|
||||
|
||||
await expect(scenario.run(reason)).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('merge answers 404, not "moved meanwhile", when a merged colony vanished between read and lock', async () => {
|
||||
setupMerge('apiary-2');
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(where.id === 'colony-1' ? parentColony : null),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('a null reason counts as omitted and asks for no permission', async () => {
|
||||
setupMove();
|
||||
restrict('apiary-2');
|
||||
|
||||
await expect(
|
||||
service.move(
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2', restrictionOverrideReason: null },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
expect(resolver.getPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', async () => {
|
||||
setupMove();
|
||||
|
||||
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
|
||||
expect(lockSql()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
|
||||
]);
|
||||
const [[shareQuery]] = tx.$queryRaw.mock.calls as [
|
||||
[{ values: unknown[] }],
|
||||
];
|
||||
expect(shareQuery.values).toEqual([['apiary-1', 'apiary-2']]);
|
||||
const order = [
|
||||
tx.$queryRaw.mock.invocationCallOrder[0],
|
||||
tx.$queryRaw.mock.invocationCallOrder[1],
|
||||
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||
tx.colony.update.mock.invocationCallOrder[0],
|
||||
];
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
expect(tx.apiary.findUnique.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
tx.$queryRaw.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('create locks the apiary (share) before it checks and writes', async () => {
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-9',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
|
||||
await service.create(createDto, 'user-1');
|
||||
|
||||
expect(lockSql()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||
]);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(
|
||||
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(tx.colony.create.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('merge locks the apiaries (share), then survivor and merged colonies in one statement', async () => {
|
||||
setupMerge('apiary-2');
|
||||
|
||||
await service.merge(
|
||||
'colony-1',
|
||||
{ mergedColonyIds: ['colony-3'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(lockSql()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
]);
|
||||
expect(
|
||||
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(tx.$queryRaw.mock.invocationCallOrder[1]);
|
||||
});
|
||||
|
||||
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', async () => {
|
||||
const movedAway = { ...parentColony, apiaryId: 'apiary-3' };
|
||||
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.findUnique.mockResolvedValue(movedAway);
|
||||
await expect(
|
||||
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||
await expect(
|
||||
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||
|
||||
setupMerge('apiary-2');
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
where.id === 'colony-1'
|
||||
? parentColony
|
||||
: { id: 'colony-3', apiaryId: 'apiary-3', status: 'ACTIVE' },
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(
|
||||
where.id === 'colony-1'
|
||||
? movedAway
|
||||
: { id: 'colony-3', apiaryId: 'apiary-2', status: 'ACTIVE' },
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 409, message: reloadMessage });
|
||||
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('an update without an apiary change still works when the colony was moved meanwhile', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-3',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
await expect(
|
||||
service.update('colony-1', { notes: 'x' }, 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('updateIn called from an inspection takeover locks no apiary', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
await service.updateIn(tx as never, 'colony-1', { notes: 'x' }, 'user-1');
|
||||
|
||||
expect(lockSql()).toEqual([
|
||||
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
|
||||
]);
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateIn refuses to move the apiary without the locks update() takes', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||
|
||||
await expect(
|
||||
service.updateIn(
|
||||
tx as never,
|
||||
'colony-1',
|
||||
{ apiaryId: 'apiary-2' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('moving the apiary');
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -21,9 +22,21 @@ import {
|
||||
isLivingColonyStatus,
|
||||
TERMINAL_COLONY_STATUSES,
|
||||
} from './colony-status';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import {
|
||||
assertNoActiveRestriction,
|
||||
RestrictionCheck,
|
||||
RestrictionOverride,
|
||||
} from '../compliance/restricted-zone';
|
||||
import {
|
||||
apiaryChangedMeanwhile,
|
||||
NOT_OVERRIDDEN,
|
||||
overrideNote,
|
||||
overrideTrace,
|
||||
} from './restriction-override';
|
||||
|
||||
function colonyMovedEvent(
|
||||
colonyId: string,
|
||||
@@ -40,6 +53,21 @@ function colonyMovedEvent(
|
||||
};
|
||||
}
|
||||
|
||||
/** The fields update() writes: the override reason is read, never stored. */
|
||||
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
|
||||
|
||||
/**
|
||||
* What update() hands to updateIn when it moves the colony to another apiary:
|
||||
* the apiary the unlocked read saw (the apiary rows were locked for it) and
|
||||
* the override the user may use.
|
||||
*/
|
||||
export interface ApiaryMoveGuard {
|
||||
seenApiaryId: string;
|
||||
override: RestrictionOverride;
|
||||
}
|
||||
|
||||
const OVERRIDE_PERMISSION = 'compliance:manage';
|
||||
|
||||
export interface ColonyFilter {
|
||||
status?: ColonyStatus;
|
||||
apiaryId?: string;
|
||||
@@ -52,6 +80,7 @@ export class ColoniesService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||
@@ -84,11 +113,14 @@ export class ColoniesService {
|
||||
// this, a colony could be created or moved into an apiary that either
|
||||
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
|
||||
// 404) or is deactivated (silently orphaning the colony the same way an
|
||||
// apiary deactivated out from under an existing colony would).
|
||||
private async assertApiaryActive(apiaryId: string): Promise<void> {
|
||||
const apiary = await this.prisma.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
});
|
||||
// apiary deactivated out from under an existing colony would). Read inside
|
||||
// the transaction, after the apiary rows are locked (FOR SHARE): the
|
||||
// apiary cannot be deactivated before this colony path commits.
|
||||
private async assertApiaryActiveIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
): Promise<void> {
|
||||
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
@@ -99,17 +131,49 @@ export class ColoniesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Asked once per request, and only when a reason was sent (null counts as
|
||||
// omitted): the right to override is compliance:manage.
|
||||
private async overrideOf(
|
||||
reason: string | null | undefined,
|
||||
actingUserId: string,
|
||||
): Promise<RestrictionOverride> {
|
||||
if (!reason) {
|
||||
return { reason: null, canOverride: false };
|
||||
}
|
||||
const held = await this.permissions.getPermissions(actingUserId);
|
||||
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
|
||||
// when no restriction is active. Checked before anything is read or locked.
|
||||
if (!held.has(OVERRIDE_PERMISSION)) {
|
||||
throw new ForbiddenException(
|
||||
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
|
||||
);
|
||||
}
|
||||
return { reason, canOverride: true };
|
||||
}
|
||||
|
||||
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
||||
await this.assertApiaryActive(dto.apiaryId);
|
||||
const { restrictionOverrideReason, ...data } = dto;
|
||||
const override = await this.overrideOf(
|
||||
restrictionOverrideReason,
|
||||
actingUserId,
|
||||
);
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.colony.create({ data: { ...dto } });
|
||||
await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
|
||||
await this.assertApiaryActiveIn(tx, data.apiaryId);
|
||||
const check = await assertNoActiveRestriction(
|
||||
tx,
|
||||
[data.apiaryId],
|
||||
berlinToday(),
|
||||
override,
|
||||
);
|
||||
const created = await tx.colony.create({ data });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
afterData: { ...created, ...overrideTrace(check, override.reason) },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -126,9 +190,25 @@ export class ColoniesService {
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
const { after } = await this.prisma.$transaction((tx) =>
|
||||
this.updateIn(tx, id, dto, actingUserId),
|
||||
const { restrictionOverrideReason, ...patch } = dto;
|
||||
const override = await this.overrideOf(
|
||||
restrictionOverrideReason,
|
||||
actingUserId,
|
||||
);
|
||||
const seen = await this.findById(id);
|
||||
// Only a change of the apiary involves apiaries: the old and the new one
|
||||
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
|
||||
const movesApiary =
|
||||
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
|
||||
const { after } = await this.prisma.$transaction(async (tx) => {
|
||||
if (movesApiary) {
|
||||
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
|
||||
}
|
||||
return this.updateIn(tx, id, patch, actingUserId, {
|
||||
seenApiaryId: seen.apiaryId,
|
||||
override,
|
||||
});
|
||||
});
|
||||
return after;
|
||||
}
|
||||
|
||||
@@ -137,22 +217,44 @@ export class ColoniesService {
|
||||
* inspection handing values over to the colony card). Locks the colony row
|
||||
* and reads "before" only afterwards (404 when missing); writes the apiary
|
||||
* history, the status events and the audit entry with the change.
|
||||
*
|
||||
* It locks no apiary itself. A patch that moves the colony to another
|
||||
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
|
||||
* before the colony, so the restriction check and the write cannot be
|
||||
* overtaken by a restriction or a deactivation. The inspection takeover
|
||||
* passes no apiary and no guard.
|
||||
*/
|
||||
async updateIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
dto: UpdateColonyDto,
|
||||
dto: ColonyPatch,
|
||||
actingUserId: string,
|
||||
guard?: ApiaryMoveGuard,
|
||||
): Promise<{ before: Colony; after: Colony }> {
|
||||
const before = await lockColony(tx, id);
|
||||
const today = berlinToday();
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
let check: RestrictionCheck = NOT_OVERRIDDEN;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
if (!guard) {
|
||||
throw new Error(
|
||||
'ColoniesService.updateIn: moving the apiary needs update()',
|
||||
);
|
||||
}
|
||||
if (before.apiaryId !== guard.seenApiaryId) {
|
||||
throw apiaryChangedMeanwhile();
|
||||
}
|
||||
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
|
||||
check = await assertNoActiveRestriction(
|
||||
tx,
|
||||
[before.apiaryId, dto.apiaryId!],
|
||||
today,
|
||||
guard.override,
|
||||
);
|
||||
}
|
||||
// An edit into an ended status ends the queen assignment like dissolve
|
||||
// does, before the status is written (the pointer is read under the lock).
|
||||
const today = berlinToday();
|
||||
const endsColony =
|
||||
dto.status !== undefined &&
|
||||
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
|
||||
@@ -173,7 +275,11 @@ export class ColoniesService {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
notes: overrideNote(
|
||||
check,
|
||||
guard?.override.reason,
|
||||
'Korrektur über Volk bearbeiten',
|
||||
),
|
||||
},
|
||||
});
|
||||
await this.events.record(
|
||||
@@ -196,7 +302,10 @@ export class ColoniesService {
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
afterData: {
|
||||
...updated,
|
||||
...overrideTrace(check, guard?.override.reason),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -208,21 +317,41 @@ export class ColoniesService {
|
||||
dto: MoveColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
this.assertActive(await this.findById(id));
|
||||
await this.assertApiaryActive(dto.apiaryId);
|
||||
const { restrictionOverrideReason, ...data } = dto;
|
||||
const override = await this.overrideOf(
|
||||
restrictionOverrideReason,
|
||||
actingUserId,
|
||||
);
|
||||
const seen = await this.findById(id);
|
||||
this.assertActive(seen);
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
|
||||
const before = await lockColony(tx, id);
|
||||
this.assertActive(before);
|
||||
if (before.apiaryId !== seen.apiaryId) {
|
||||
throw apiaryChangedMeanwhile();
|
||||
}
|
||||
await this.assertApiaryActiveIn(tx, data.apiaryId);
|
||||
// A "move" within one apiary leaves the herd where it is.
|
||||
const check =
|
||||
before.apiaryId === data.apiaryId
|
||||
? NOT_OVERRIDDEN
|
||||
: await assertNoActiveRestriction(
|
||||
tx,
|
||||
[before.apiaryId, data.apiaryId],
|
||||
berlinToday(),
|
||||
override,
|
||||
);
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { apiaryId: dto.apiaryId },
|
||||
data: { apiaryId: data.apiaryId },
|
||||
});
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: dto.apiaryId,
|
||||
apiaryId: data.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: dto.notes,
|
||||
notes: overrideNote(check, override.reason, data.notes),
|
||||
},
|
||||
});
|
||||
if (before.apiaryId !== updated.apiaryId) {
|
||||
@@ -238,7 +367,7 @@ export class ColoniesService {
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
afterData: { ...updated, ...overrideTrace(check, override.reason) },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -313,24 +442,54 @@ export class ColoniesService {
|
||||
dto: MergeColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
const { restrictionOverrideReason, ...data } = dto;
|
||||
const survivor = await this.findById(id);
|
||||
this.assertActive(survivor);
|
||||
if (dto.mergedColonyIds.includes(survivor.id)) {
|
||||
if (data.mergedColonyIds.includes(survivor.id)) {
|
||||
throw new BadRequestException(
|
||||
'Ein Volk kann nicht mit sich selbst vereinigt werden',
|
||||
);
|
||||
}
|
||||
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
|
||||
if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
|
||||
throw new BadRequestException(
|
||||
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
|
||||
);
|
||||
}
|
||||
const override = await this.overrideOf(
|
||||
restrictionOverrideReason,
|
||||
actingUserId,
|
||||
);
|
||||
const today = berlinToday();
|
||||
// The apiaries of the colonies as the unlocked read saw them: they are
|
||||
// locked first, and compared with the locked rows afterwards.
|
||||
const seenApiaries = new Map(
|
||||
(
|
||||
await this.prisma.colony.findMany({
|
||||
where: { id: { in: data.mergedColonyIds } },
|
||||
select: { id: true, apiaryId: true },
|
||||
})
|
||||
).map((colony) => [colony.id, colony.apiaryId]),
|
||||
);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForShare(tx, 'Apiary', [
|
||||
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
|
||||
]);
|
||||
// One statement in id order, the same order bulk lockRowsForShare
|
||||
// uses: no deadlock with a concurrent bulk write.
|
||||
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
|
||||
for (const mergedColonyId of dto.mergedColonyIds) {
|
||||
await lockRowsForUpdate(tx, 'Colony', [
|
||||
survivor.id,
|
||||
...data.mergedColonyIds,
|
||||
]);
|
||||
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
|
||||
if (!lockedSurvivor) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
this.assertActive(lockedSurvivor);
|
||||
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
|
||||
throw apiaryChangedMeanwhile();
|
||||
}
|
||||
const mergedRows: Colony[] = [];
|
||||
for (const mergedColonyId of data.mergedColonyIds) {
|
||||
const merged = await tx.colony.findUnique({
|
||||
where: { id: mergedColonyId },
|
||||
});
|
||||
@@ -340,6 +499,22 @@ export class ColoniesService {
|
||||
);
|
||||
}
|
||||
this.assertActive(merged);
|
||||
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
|
||||
throw apiaryChangedMeanwhile();
|
||||
}
|
||||
mergedRows.push(merged);
|
||||
}
|
||||
// Uniting colonies of different apiaries brings bees from one stand to
|
||||
// the other; within one apiary nothing leaves it.
|
||||
const apiaryIds = new Set([
|
||||
lockedSurvivor.apiaryId,
|
||||
...mergedRows.map((merged) => merged.apiaryId),
|
||||
]);
|
||||
const check =
|
||||
apiaryIds.size > 1
|
||||
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
|
||||
: NOT_OVERRIDDEN;
|
||||
for (const merged of mergedRows) {
|
||||
// currentQueenId is unique across colonies - if it stayed set on
|
||||
// this now-dead colony, any later attempt to assign her queen to a
|
||||
// different colony would fail with a unique-constraint conflict.
|
||||
@@ -348,11 +523,11 @@ export class ColoniesService {
|
||||
// merge is a beekeeper decision, not one to guess here).
|
||||
const releasedQueenId = await releaseQueenIn(
|
||||
tx,
|
||||
mergedColonyId,
|
||||
merged.id,
|
||||
dayToDate(today),
|
||||
);
|
||||
const mergedAway = await tx.colony.update({
|
||||
where: { id: mergedColonyId },
|
||||
where: { id: merged.id },
|
||||
data: { status: 'MERGED', deactivatedAt: new Date() },
|
||||
});
|
||||
await this.events.recordAll(
|
||||
@@ -366,21 +541,25 @@ export class ColoniesService {
|
||||
await tx.colonyLineage.create({
|
||||
data: {
|
||||
type: 'MERGE',
|
||||
fromColonyId: mergedColonyId,
|
||||
toColonyId: survivor.id,
|
||||
fromColonyId: merged.id,
|
||||
toColonyId: lockedSurvivor.id,
|
||||
date: new Date(),
|
||||
notes: dto.notes,
|
||||
notes: data.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: survivor.id,
|
||||
entityId: lockedSurvivor.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: survivor,
|
||||
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
||||
beforeData: lockedSurvivor,
|
||||
afterData: {
|
||||
...lockedSurvivor,
|
||||
mergedColonyIds: data.mergedColonyIds,
|
||||
...overrideTrace(check, override.reason),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { ColonyCondition, ColonyType } from '../../../generated/prisma';
|
||||
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||
|
||||
export class CreateColonyDto {
|
||||
@ApiProperty()
|
||||
@@ -67,4 +68,7 @@ export class CreateColonyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@RestrictionOverrideReason()
|
||||
restrictionOverrideReason?: string | null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
|
||||
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||
|
||||
export class MergeColonyDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
@@ -12,4 +13,7 @@ export class MergeColonyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@RestrictionOverrideReason()
|
||||
restrictionOverrideReason?: string | null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||
|
||||
export class MoveColonyDto {
|
||||
@ApiProperty()
|
||||
@@ -10,4 +11,7 @@ export class MoveColonyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@RestrictionOverrideReason()
|
||||
restrictionOverrideReason?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export const OVERRIDE_REASON_MIN = 10;
|
||||
export const OVERRIDE_REASON_MAX = 500;
|
||||
|
||||
/**
|
||||
* `restrictionOverrideReason`: why a colony may enter or leave an apiary in a
|
||||
* restricted zone. Only read by ColoniesService (needs compliance:manage) and
|
||||
* written to audit and apiary history - never to the colony. Trimmed before
|
||||
* the length check, so blanks do not count; null counts as omitted.
|
||||
*/
|
||||
export function RestrictionOverrideReason(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ApiPropertyOptional({
|
||||
nullable: true,
|
||||
minLength: OVERRIDE_REASON_MIN,
|
||||
maxLength: OVERRIDE_REASON_MAX,
|
||||
description:
|
||||
'Begründung für eine Ausnahme vom Sperrbezirk (nur mit compliance:manage)',
|
||||
}),
|
||||
IsOptional(),
|
||||
Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
),
|
||||
IsString(),
|
||||
MinLength(OVERRIDE_REASON_MIN),
|
||||
MaxLength(OVERRIDE_REASON_MAX),
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { IsEnum } from 'class-validator';
|
||||
import { ColonyStatus } from '../../../generated/prisma';
|
||||
import { CreateColonyDto } from './create-colony.dto';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
import { RestrictionOverrideReason } from './restriction-override-reason';
|
||||
|
||||
/**
|
||||
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
|
||||
@@ -32,4 +33,7 @@ export class UpdateColonyDto extends UpdatableColonyFieldsDto {
|
||||
@IsOptionalNotNull()
|
||||
@IsEnum(ColonyStatus)
|
||||
status?: ColonyStatus;
|
||||
|
||||
@RestrictionOverrideReason()
|
||||
restrictionOverrideReason?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import type { RestrictionCheck } from '../compliance/restricted-zone';
|
||||
|
||||
/** 409 when the colony or an apiary changed between the unlocked read and the lock. */
|
||||
export function apiaryChangedMeanwhile(): ConflictException {
|
||||
return new ConflictException(
|
||||
'Der Stand wurde gerade geändert. Bitte neu laden.',
|
||||
);
|
||||
}
|
||||
|
||||
export const NOT_OVERRIDDEN: RestrictionCheck = {
|
||||
overridden: false,
|
||||
restrictionIds: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* The audit trace of an override: only the reason and the restriction ids,
|
||||
* and only when an active restriction was really overridden. Spread into the
|
||||
* audit entry's afterData - never into data written to the colony.
|
||||
*/
|
||||
export function overrideTrace(
|
||||
check: RestrictionCheck,
|
||||
reason: string | null | undefined,
|
||||
): { restrictionOverride?: { reason: string; restrictionIds: string[] } } {
|
||||
return check.overridden && reason
|
||||
? { restrictionOverride: { reason, restrictionIds: check.restrictionIds } }
|
||||
: {};
|
||||
}
|
||||
|
||||
/** The apiary-history note: the override in front of an existing note. */
|
||||
export function overrideNote(
|
||||
check: RestrictionCheck,
|
||||
reason: string | null | undefined,
|
||||
note: string | undefined,
|
||||
): string | undefined {
|
||||
if (!check.overridden || !reason) {
|
||||
return note;
|
||||
}
|
||||
const prefix = `Ausnahme Sperrbezirk: ${reason}`;
|
||||
return note ? `${prefix}\n${note}` : prefix;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
|
||||
import { MarkSentDto } from './dto/mark-sent.dto';
|
||||
import { NoticeListQuery } from './dto/notice-list-query.dto';
|
||||
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||
|
||||
@ApiTags('authority-notices')
|
||||
@ApiBearerAuth()
|
||||
@Controller('authority-notices')
|
||||
export class AuthorityNoticesController {
|
||||
constructor(private readonly notices: AuthorityNoticesService) {}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get()
|
||||
findAll(@Query() query: NoticeListQuery) {
|
||||
return this.notices.findAll(query);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.notices.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateAuthorityNoticeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.notices.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAuthorityNoticeDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.notices.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Post(':id/recount')
|
||||
recount(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.notices.recount(id, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Post(':id/mark-sent')
|
||||
markSent(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MarkSentDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.notices.markSent(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Post(':id/reopen')
|
||||
reopen(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.notices.reopen(id, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Post(':id/cancel')
|
||||
cancel(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.notices.cancel(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const lockMock = lockRowForUpdate as jest.Mock;
|
||||
|
||||
const SETTINGS = {
|
||||
id: 'singleton',
|
||||
tskExcludedColonyTypes: [] as string[],
|
||||
};
|
||||
|
||||
function notice(patch: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'HERD_CHANGE',
|
||||
status: 'DRAFT',
|
||||
year: null,
|
||||
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||
countedAt: new Date('2027-01-01T10:00:00.000Z'),
|
||||
snapshot: { version: 1, apiaries: [], totalLiving: 2 },
|
||||
totalColonies: 2,
|
||||
sentOn: null,
|
||||
sentVia: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
openKey: 'HERD_CHANGE',
|
||||
createdByUserId: 'u1',
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function p2002(): Prisma.PrismaClientKnownRequestError {
|
||||
return new Prisma.PrismaClientKnownRequestError('unique', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
}
|
||||
|
||||
describe('AuthorityNoticesService', () => {
|
||||
let service: AuthorityNoticesService;
|
||||
let tx: {
|
||||
complianceSettings: { upsert: jest.Mock; findUniqueOrThrow: jest.Mock };
|
||||
authorityNotice: {
|
||||
findFirst: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
apiary: { findMany: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
authorityNotice: { findFirst: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
// The baseline lookup and the open-draft lookup both use findFirst: the
|
||||
// helper answers by the where clause.
|
||||
function stubLookups(opts: {
|
||||
baseline?: unknown;
|
||||
openDraft?: unknown;
|
||||
}): void {
|
||||
tx.authorityNotice.findFirst.mockImplementation(
|
||||
({ where }: { where: { openKey?: string; status?: string } }) =>
|
||||
Promise.resolve(
|
||||
where.openKey !== undefined
|
||||
? (opts.openDraft ?? null)
|
||||
: (opts.baseline ?? null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lockMock.mockReset().mockResolvedValue(true);
|
||||
tx = {
|
||||
complianceSettings: {
|
||||
upsert: jest.fn().mockResolvedValue(SETTINGS),
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue(SETTINGS),
|
||||
},
|
||||
authorityNotice: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findUnique: jest.fn().mockResolvedValue(notice()),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve(notice(data as Record<string, unknown>)),
|
||||
),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve(notice(data as Record<string, unknown>)),
|
||||
),
|
||||
},
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'a1',
|
||||
name: 'Stand 1',
|
||||
address: null,
|
||||
location: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
colonies: [
|
||||
{ colonyType: 'PRODUCTION_COLONY' },
|
||||
{ colonyType: 'NUCLEUS_COLONY' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
authorityNotice: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new AuthorityNoticesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('one open draft per kind and year', async () => {
|
||||
stubLookups({
|
||||
baseline: notice({ status: 'SENT' }),
|
||||
openDraft: { id: 'existing' },
|
||||
});
|
||||
|
||||
const attempt = service.create({ kind: 'HERD_CHANGE' }, 'u1');
|
||||
|
||||
await expect(attempt).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(attempt).rejects.toMatchObject({
|
||||
response: { code: 'DRAFT_EXISTS', existingId: 'existing' },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers DRAFT_EXISTS when the unique key of the open draft is hit', async () => {
|
||||
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||
tx.authorityNotice.create.mockRejectedValueOnce(p2002());
|
||||
prisma.authorityNotice.findFirst.mockResolvedValue({ id: 'winner' });
|
||||
|
||||
await expect(
|
||||
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
|
||||
).rejects.toMatchObject({
|
||||
response: { code: 'DRAFT_EXISTS', existingId: 'winner' },
|
||||
});
|
||||
});
|
||||
|
||||
it('create with sentOn is SENT at once and has no openKey', async () => {
|
||||
await service.create(
|
||||
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
kind: 'REGISTRATION',
|
||||
status: 'SENT',
|
||||
sentOn: new Date('2026-05-01T00:00:00.000Z'),
|
||||
openKey: null,
|
||||
totalColonies: 2,
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a notice created as sent does not collide with an open draft', async () => {
|
||||
stubLookups({ openDraft: { id: 'existing' } });
|
||||
|
||||
await service.create(
|
||||
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a unique violation that no open draft explains', async () => {
|
||||
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||
const error = p2002();
|
||||
tx.authorityNotice.create.mockRejectedValueOnce(error);
|
||||
prisma.authorityNotice.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create({ kind: 'HERD_CHANGE' }, 'u1')).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('a draft carries the open key and the counted snapshot', async () => {
|
||||
await service.create({ kind: 'REGISTRATION' }, 'u1');
|
||||
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
status: 'DRAFT',
|
||||
openKey: 'REGISTRATION',
|
||||
sentOn: null,
|
||||
totalColonies: 2,
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('herd change needs a baseline and registration needs none', async () => {
|
||||
stubLookups({});
|
||||
await expect(
|
||||
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
|
||||
).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
message: expect.stringContaining('Erstanzeige') as unknown,
|
||||
}) as unknown,
|
||||
});
|
||||
|
||||
stubLookups({ baseline: notice({ status: 'SENT' }) });
|
||||
await expect(
|
||||
service.create({ kind: 'REGISTRATION' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('TSK_REPORT needs a year and uses it in the open key', async () => {
|
||||
await expect(
|
||||
service.create({ kind: 'TSK_REPORT' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
|
||||
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
year: 2027,
|
||||
openKey: 'TSK_REPORT:2027',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a year is only valid for the TSK report', async () => {
|
||||
await expect(
|
||||
service.create({ kind: 'REGISTRATION', year: 2027 }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('the TSK count leaves out the excluded colony types', async () => {
|
||||
tx.complianceSettings.findUniqueOrThrow.mockResolvedValue({
|
||||
...SETTINGS,
|
||||
tskExcludedColonyTypes: ['NUCLEUS_COLONY'],
|
||||
});
|
||||
|
||||
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
|
||||
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ totalColonies: 1 }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks the settings row as mutex before reading and audits the create', async () => {
|
||||
await service.create({ kind: 'REGISTRATION' }, 'u1');
|
||||
|
||||
expect(lockMock).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'ComplianceSettings',
|
||||
'singleton',
|
||||
);
|
||||
expect(lockMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.authorityNotice.findFirst.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'AuthorityNotice',
|
||||
action: 'CREATE',
|
||||
userId: 'u1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDraftIn', () => {
|
||||
it('is the shared core: no actor needed and the caller owns the transaction', async () => {
|
||||
await service.createDraftIn(
|
||||
tx as never,
|
||||
{
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
createdByUserId: null,
|
||||
openKey: 'TSK_REPORT:2027',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitions', () => {
|
||||
it('recount only in DRAFT', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null }),
|
||||
);
|
||||
|
||||
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(notice());
|
||||
await service.recount('n1', 'u1');
|
||||
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({ totalColonies: 2 }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('mark-sent on CANCELLED and recount on SENT answer 409', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'CANCELLED', openKey: null }),
|
||||
);
|
||||
await expect(service.markSent('n1', {}, 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null }),
|
||||
);
|
||||
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
|
||||
try {
|
||||
await service.markSent('n1', {}, 'u1');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
sentOn: new Date('2027-03-02T00:00:00.000Z'),
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
|
||||
await service.markSent(
|
||||
'n1',
|
||||
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'SENT',
|
||||
sentOn: new Date('2027-02-02T00:00:00.000Z'),
|
||||
sentVia: 'E-Mail',
|
||||
openKey: null,
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('a reopened registration can be marked sent again', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ kind: 'REGISTRATION', openKey: 'REGISTRATION' }),
|
||||
);
|
||||
stubLookups({ baseline: notice({ id: 'other', status: 'SENT' }) });
|
||||
|
||||
await service.markSent('n1', {}, 'u1');
|
||||
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({ status: 'SENT' }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('reopen answers 409 while another draft of the kind exists', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null }),
|
||||
);
|
||||
stubLookups({ openDraft: { id: 'other' } });
|
||||
|
||||
await expect(service.reopen('n1', 'u1')).rejects.toMatchObject({
|
||||
response: { code: 'DRAFT_EXISTS', existingId: 'other' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reopen turns a SENT notice back into a draft with its open key', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
|
||||
);
|
||||
|
||||
await service.reopen('n1', 'u1');
|
||||
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'DRAFT',
|
||||
openKey: 'HERD_CHANGE',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel only works on a draft and drops the open key', async () => {
|
||||
await service.cancel('n1', 'u1');
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'CANCELLED',
|
||||
openKey: null,
|
||||
}) as unknown,
|
||||
});
|
||||
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null }),
|
||||
);
|
||||
await expect(service.cancel('n1', 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['recount', (s: AuthorityNoticesService) => s.recount('n1', 'u1')],
|
||||
['markSent', (s: AuthorityNoticesService) => s.markSent('n1', {}, 'u1')],
|
||||
['cancel', (s: AuthorityNoticesService) => s.cancel('n1', 'u1')],
|
||||
[
|
||||
'update',
|
||||
(s: AuthorityNoticesService) => s.update('n1', { notes: 'x' }, 'u1'),
|
||||
],
|
||||
])(
|
||||
'every transition locks the notice row and writes an audit entry (%s)',
|
||||
async (_name, run) => {
|
||||
await run(service);
|
||||
|
||||
expect(lockMock).toHaveBeenCalledWith(tx, 'AuthorityNotice', 'n1');
|
||||
const lockOrder = lockMock.mock.invocationCallOrder.at(-1) ?? 0;
|
||||
expect(lockOrder).toBeLessThan(
|
||||
tx.authorityNotice.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'AuthorityNotice',
|
||||
entityId: 'n1',
|
||||
action: 'UPDATE',
|
||||
userId: 'u1',
|
||||
beforeData: expect.anything() as unknown,
|
||||
afterData: expect.anything() as unknown,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('reopen locks the settings mutex before the notice row', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null }),
|
||||
);
|
||||
|
||||
await service.reopen('n1', 'u1');
|
||||
|
||||
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||
'ComplianceSettings',
|
||||
'AuthorityNotice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers 404 for a missing notice', async () => {
|
||||
lockMock.mockResolvedValue(false);
|
||||
|
||||
await expect(service.recount('missing', 'u1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('changes the corrected fields but answers 409 on CANCELLED', async () => {
|
||||
await service.update(
|
||||
'n1',
|
||||
{ totalColonies: 7, notes: 'korrigiert' },
|
||||
'u1',
|
||||
);
|
||||
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
|
||||
where: { id: 'n1' },
|
||||
data: expect.objectContaining({
|
||||
totalColonies: 7,
|
||||
notes: 'korrigiert',
|
||||
}) as unknown,
|
||||
});
|
||||
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'CANCELLED', openKey: null }),
|
||||
);
|
||||
await expect(
|
||||
service.update('n1', { notes: 'x' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('a draft has no sent day', async () => {
|
||||
await expect(
|
||||
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('changing the sent day takes the settings mutex first', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
|
||||
);
|
||||
|
||||
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
|
||||
|
||||
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||
'ComplianceSettings',
|
||||
'AuthorityNotice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('other edits do not take the settings mutex', async () => {
|
||||
await service.update('n1', { notes: 'x' }, 'u1');
|
||||
|
||||
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
|
||||
'AuthorityNotice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a sent notice keeps its sent day', async () => {
|
||||
tx.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.update('n1', { sentOn: null }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll / findOne', () => {
|
||||
it('filters by kind and year', async () => {
|
||||
await service.findAll({ kind: 'TSK_REPORT', year: 2027 });
|
||||
|
||||
expect(prisma.authorityNotice.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { kind: 'TSK_REPORT', year: 2027 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown id', async () => {
|
||||
(
|
||||
prisma.authorityNotice as unknown as { findUnique: jest.Mock }
|
||||
).findUnique = jest.fn().mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('x')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AuthorityNotice,
|
||||
AuthorityNoticeKind,
|
||||
Prisma,
|
||||
} from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
|
||||
import { MarkSentDto } from './dto/mark-sent.dto';
|
||||
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||
import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot';
|
||||
import { COMPLIANCE_SETTINGS_ID, findBaselineIn } from './herd-status';
|
||||
|
||||
const AUDIT_ENTITY = 'AuthorityNotice';
|
||||
const LIST_LIMIT = 200;
|
||||
const UNIQUE_VIOLATION = 'P2002';
|
||||
|
||||
type NoticeUpdate = Prisma.AuthorityNoticeUncheckedUpdateInput;
|
||||
|
||||
interface DraftInput {
|
||||
kind: AuthorityNoticeKind;
|
||||
year?: number;
|
||||
referenceDate: Date;
|
||||
}
|
||||
|
||||
interface TransitionOptions {
|
||||
/** Takes the ComplianceSettings mutex first (the open key is involved). */
|
||||
mutex?: boolean;
|
||||
}
|
||||
|
||||
/** "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT:<year>": at most one draft each. */
|
||||
function openKeyOf(kind: AuthorityNoticeKind, year: number | null): string {
|
||||
return kind === 'TSK_REPORT' ? `TSK_REPORT:${year}` : kind;
|
||||
}
|
||||
|
||||
function trimmedOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function notAllowed(message: string): ConflictException {
|
||||
return new ConflictException({ statusCode: 409, message });
|
||||
}
|
||||
|
||||
function draftExists(existingId: string): ConflictException {
|
||||
return new ConflictException({
|
||||
statusCode: 409,
|
||||
code: 'DRAFT_EXISTS',
|
||||
message: 'Es gibt schon einen offenen Entwurf dieser Anzeige.',
|
||||
existingId,
|
||||
});
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
|
||||
if (kind === 'TSK_REPORT' && year === undefined) {
|
||||
throw new BadRequestException('Die TSK-Zählung braucht ein Jahr');
|
||||
}
|
||||
if (kind !== 'TSK_REPORT' && year !== undefined) {
|
||||
throw new BadRequestException('Ein Jahr gibt es nur bei der TSK-Zählung');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthorityNoticesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
findAll(q: {
|
||||
kind?: AuthorityNoticeKind;
|
||||
year?: number;
|
||||
}): Promise<AuthorityNotice[]> {
|
||||
return this.prisma.authorityNotice.findMany({
|
||||
where: { kind: q.kind, year: q.year },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: LIST_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<AuthorityNotice> {
|
||||
const notice = await this.prisma.authorityNotice.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!notice) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a draft, or a SENT notice at once when `sentOn` is given (the
|
||||
* way to record what was already reported). A second open draft of the
|
||||
* same kind and year answers 409 DRAFT_EXISTS with the id of the first.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateAuthorityNoticeDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
assertYearFits(dto.kind, dto.year);
|
||||
const input = {
|
||||
kind: dto.kind,
|
||||
year: dto.year,
|
||||
referenceDate: dayToDate(dto.referenceDate ?? berlinToday()),
|
||||
sentOn: dto.sentOn ? dayToDate(dto.sentOn) : undefined,
|
||||
};
|
||||
try {
|
||||
return await this.prisma.$transaction((tx) =>
|
||||
this.insertIn(tx, input, actingUserId),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) {
|
||||
throw error;
|
||||
}
|
||||
const winner = await this.prisma.authorityNotice.findFirst({
|
||||
where: { openKey: openKeyOf(dto.kind, dto.year ?? null) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (winner) {
|
||||
throw draftExists(winner.id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared core of create: the daily TSK job calls it in its own transaction. */
|
||||
createDraftIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: DraftInput,
|
||||
userId: string | null,
|
||||
): Promise<AuthorityNotice> {
|
||||
assertYearFits(input.kind, input.year);
|
||||
return this.insertIn(tx, input, userId);
|
||||
}
|
||||
|
||||
/** Replaces the herd picture and the total; only a draft can be recounted. */
|
||||
recount(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, {}, async (tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
throw notAllowed('Nur ein Entwurf kann neu gezählt werden.');
|
||||
}
|
||||
const countedAt = new Date();
|
||||
const snapshot = await this.countIn(tx, before.kind, countedAt);
|
||||
return {
|
||||
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||
totalColonies: snapshot.totalLiving,
|
||||
countedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
markSent(
|
||||
id: string,
|
||||
dto: MarkSentDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
return Promise.reject(
|
||||
notAllowed('Nur ein Entwurf kann als gesendet markiert werden.'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 'SENT',
|
||||
sentOn: dayToDate(dto.sentOn ?? berlinToday()),
|
||||
sentVia: trimmedOrNull(dto.sentVia),
|
||||
reference: trimmedOrNull(dto.reference),
|
||||
openKey: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reopen(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(
|
||||
id,
|
||||
actingUserId,
|
||||
{ mutex: true },
|
||||
async (tx, before) => {
|
||||
if (before.status !== 'SENT') {
|
||||
throw notAllowed('Nur eine gesendete Anzeige kann geöffnet werden.');
|
||||
}
|
||||
const openKey = openKeyOf(before.kind, before.year);
|
||||
const other = await tx.authorityNotice.findFirst({
|
||||
where: { openKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (other) {
|
||||
throw draftExists(other.id);
|
||||
}
|
||||
return { status: 'DRAFT', openKey, sentOn: null };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cancel(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, {}, (_tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
return Promise.reject(
|
||||
notAllowed('Nur ein Entwurf kann verworfen werden.'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({ status: 'CANCELLED', openKey: null });
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
dto: UpdateAuthorityNoticeDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
// A new sent day can change which notice is the baseline: the mutex
|
||||
// comes first, in lock order, whenever the sent day is touched.
|
||||
const mutex = dto.sentOn !== undefined;
|
||||
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
||||
if (before.status === 'CANCELLED') {
|
||||
return Promise.reject(
|
||||
notAllowed('Eine verworfene Anzeige kann nicht geändert werden.'),
|
||||
);
|
||||
}
|
||||
if (before.status === 'DRAFT' && dto.sentOn != null) {
|
||||
return Promise.reject(
|
||||
new BadRequestException('Ein Entwurf hat kein Sendedatum.'),
|
||||
);
|
||||
}
|
||||
if (before.status === 'SENT' && dto.sentOn === null) {
|
||||
return Promise.reject(
|
||||
new BadRequestException(
|
||||
'Eine gesendete Anzeige braucht ihr Sendedatum',
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
totalColonies: dto.totalColonies,
|
||||
referenceDate: dto.referenceDate
|
||||
? dayToDate(dto.referenceDate)
|
||||
: undefined,
|
||||
sentOn: optionalDate(dto.sentOn),
|
||||
sentVia: trimmedOrNull(dto.sentVia),
|
||||
reference: trimmedOrNull(dto.reference),
|
||||
notes: trimmedOrNull(dto.notes),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async countIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
kind: AuthorityNoticeKind,
|
||||
countedAt: Date,
|
||||
) {
|
||||
const settings = await tx.complianceSettings.findUniqueOrThrow({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
return buildHerdSnapshot(
|
||||
await loadHerdApiaryRows(tx),
|
||||
countedAt,
|
||||
kind === 'TSK_REPORT' ? settings.tskExcludedColonyTypes : [],
|
||||
);
|
||||
}
|
||||
|
||||
private async insertIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: DraftInput & { sentOn?: Date },
|
||||
userId: string | null,
|
||||
): Promise<AuthorityNotice> {
|
||||
await this.lockSettingsIn(tx);
|
||||
const baseline = await findBaselineIn(tx);
|
||||
if (input.kind === 'REGISTRATION' && baseline) {
|
||||
throw notAllowed('Die Erstanzeige ist schon erfasst.');
|
||||
}
|
||||
if (input.kind === 'HERD_CHANGE' && !baseline) {
|
||||
throw notAllowed('Erst die Erstanzeige erfassen.');
|
||||
}
|
||||
const openKey = openKeyOf(input.kind, input.year ?? null);
|
||||
// A notice recorded as already sent has no open key and cannot collide.
|
||||
const draft = input.sentOn
|
||||
? null
|
||||
: await tx.authorityNotice.findFirst({
|
||||
where: { openKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (draft) {
|
||||
throw draftExists(draft.id);
|
||||
}
|
||||
const countedAt = new Date();
|
||||
const snapshot = await this.countIn(tx, input.kind, countedAt);
|
||||
const created = await tx.authorityNotice.create({
|
||||
data: {
|
||||
kind: input.kind,
|
||||
status: input.sentOn ? 'SENT' : 'DRAFT',
|
||||
year: input.year ?? null,
|
||||
referenceDate: input.referenceDate,
|
||||
countedAt,
|
||||
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||
totalColonies: snapshot.totalLiving,
|
||||
sentOn: input.sentOn ?? null,
|
||||
openKey: input.sentOn ? null : openKey,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: userId ?? undefined,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** The ComplianceSettings row is the mutex of everything around the open key. */
|
||||
private async lockSettingsIn(tx: Prisma.TransactionClient): Promise<void> {
|
||||
await tx.complianceSettings.upsert({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* One state change: lock (settings mutex first when asked, then the notice
|
||||
* row), read "before" under the lock, check the transition, write, audit.
|
||||
*/
|
||||
private async transition(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
options: TransitionOptions,
|
||||
change: (
|
||||
tx: Prisma.TransactionClient,
|
||||
before: AuthorityNotice,
|
||||
) => Promise<NoticeUpdate>,
|
||||
): Promise<AuthorityNotice> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (options.mutex) {
|
||||
await this.lockSettingsIn(tx);
|
||||
}
|
||||
if (!(await lockRowForUpdate(tx, 'AuthorityNotice', id))) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
const before = await tx.authorityNotice.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
const after = await tx.authorityNotice.update({
|
||||
where: { id },
|
||||
data: await change(tx, before),
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateComplianceSettingsDto: {
|
||||
dto: UpdateComplianceSettingsDto,
|
||||
notNull: [
|
||||
'noticeOnColonyCountChange',
|
||||
'noticeOnApiaryChange',
|
||||
'tskReferenceDay',
|
||||
'tskExcludedColonyTypes',
|
||||
'netQuantityDigitRules',
|
||||
],
|
||||
clearable: [
|
||||
'veterinaryOfficeName',
|
||||
'veterinaryOfficeAddress',
|
||||
'registrationNumber',
|
||||
'tskReportDueDay',
|
||||
'tskMemberNumber',
|
||||
],
|
||||
},
|
||||
UpdateAuthorityNoticeDto: {
|
||||
dto: UpdateAuthorityNoticeDto,
|
||||
notNull: ['totalColonies', 'referenceDate'],
|
||||
clearable: ['sentOn', 'sentVia', 'reference', 'notes'],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Compliance DTO null contracts', contracts);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { ComplianceSettingsService } from './compliance-settings.service';
|
||||
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||
|
||||
@ApiTags('compliance')
|
||||
@ApiBearerAuth()
|
||||
@Controller('compliance/settings')
|
||||
export class ComplianceSettingsController {
|
||||
constructor(private readonly settings: ComplianceSettingsService) {}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get()
|
||||
get() {
|
||||
return this.settings.get();
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Patch()
|
||||
update(
|
||||
@Body() dto: UpdateComplianceSettingsDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.settings.update(dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ComplianceSettings } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
|
||||
import { complianceSettingsIn, COMPLIANCE_SETTINGS_ID } from './herd-status';
|
||||
import { isValidMonthDay } from './tsk-day';
|
||||
|
||||
const AUDIT_ENTITY = 'ComplianceSettings';
|
||||
const MIN_DIGIT_RULES = 1;
|
||||
const MAX_DIGIT_RULES = 10;
|
||||
const MIN_DIGIT_MM = 0.5;
|
||||
const MAX_DIGIT_MM = 20;
|
||||
|
||||
interface DigitRuleInput {
|
||||
maxGrams: number | null;
|
||||
minMm: number;
|
||||
}
|
||||
|
||||
// undefined keeps the stored value, null or blank clears it, anything else is
|
||||
// stored trimmed.
|
||||
function trimmedOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function assertMonthDay(field: string, value: string | null | undefined): void {
|
||||
if (value != null && !isValidMonthDay(value)) {
|
||||
throw new BadRequestException(
|
||||
`${field} muss ein gültiger Tag im Format MM-TT sein (ohne 29. Februar)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 to 10 rules, the limits strictly ascending, only the last without a
|
||||
* limit, every height between 0.5 and 20 mm. The values themselves are the
|
||||
* owner's assumption - nothing here knows what the law asks for.
|
||||
*/
|
||||
function assertDigitRules(rules: readonly DigitRuleInput[]): void {
|
||||
if (rules.length < MIN_DIGIT_RULES || rules.length > MAX_DIGIT_RULES) {
|
||||
throw new BadRequestException(
|
||||
`Es braucht ${MIN_DIGIT_RULES} bis ${MAX_DIGIT_RULES} Regeln für die Ziffernhöhe`,
|
||||
);
|
||||
}
|
||||
let previous = 0;
|
||||
rules.forEach((rule, index) => {
|
||||
const isLast = index === rules.length - 1;
|
||||
if (rule.minMm < MIN_DIGIT_MM || rule.minMm > MAX_DIGIT_MM) {
|
||||
throw new BadRequestException(
|
||||
`Die Ziffernhöhe muss zwischen ${MIN_DIGIT_MM} und ${MAX_DIGIT_MM} mm liegen`,
|
||||
);
|
||||
}
|
||||
if (isLast) {
|
||||
if (rule.maxGrams !== null) {
|
||||
throw new BadRequestException(
|
||||
'Die letzte Regel gilt für alles darüber und hat keine Obergrenze',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rule.maxGrams === null || rule.maxGrams <= previous) {
|
||||
throw new BadRequestException(
|
||||
'Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine',
|
||||
);
|
||||
}
|
||||
previous = rule.maxGrams;
|
||||
});
|
||||
}
|
||||
|
||||
function assertUnique(values: readonly string[]): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new BadRequestException('Eine Völkerart steht doppelt in der Liste');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ComplianceSettingsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/** Find-or-create, like SiteSettings. */
|
||||
get(): Promise<ComplianceSettings> {
|
||||
return complianceSettingsIn(this.prisma);
|
||||
}
|
||||
|
||||
async update(
|
||||
dto: UpdateComplianceSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<ComplianceSettings> {
|
||||
assertMonthDay('Der Stichtag', dto.tskReferenceDay);
|
||||
assertMonthDay('Die Meldefrist', dto.tskReportDueDay);
|
||||
if (dto.tskExcludedColonyTypes) {
|
||||
assertUnique(dto.tskExcludedColonyTypes);
|
||||
}
|
||||
const digitRules = dto.netQuantityDigitRules?.map((rule) => ({
|
||||
maxGrams: rule.maxGrams ?? null,
|
||||
minMm: rule.minMm,
|
||||
}));
|
||||
if (digitRules) {
|
||||
assertDigitRules(digitRules);
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The row must exist to be locked; "before" is read after the lock.
|
||||
await tx.complianceSettings.upsert({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||
const before = await tx.complianceSettings.findUniqueOrThrow({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
const settings = await tx.complianceSettings.update({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
data: {
|
||||
veterinaryOfficeName: trimmedOrNull(dto.veterinaryOfficeName),
|
||||
veterinaryOfficeAddress: trimmedOrNull(dto.veterinaryOfficeAddress),
|
||||
registrationNumber: trimmedOrNull(dto.registrationNumber),
|
||||
noticeOnColonyCountChange: dto.noticeOnColonyCountChange,
|
||||
noticeOnApiaryChange: dto.noticeOnApiaryChange,
|
||||
tskReferenceDay: dto.tskReferenceDay,
|
||||
tskReportDueDay: dto.tskReportDueDay,
|
||||
tskMemberNumber: trimmedOrNull(dto.tskMemberNumber),
|
||||
tskExcludedColonyTypes: dto.tskExcludedColonyTypes,
|
||||
netQuantityDigitRules: digitRules,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: COMPLIANCE_SETTINGS_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return settings;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthorityNoticesController } from './authority-notices.controller';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
import { ComplianceSettingsController } from './compliance-settings.controller';
|
||||
import { ComplianceSettingsService } from './compliance-settings.service';
|
||||
|
||||
// No provider here may inject PermissionResolverService: the worker imports
|
||||
// this module too (handlers arrive with the notification task).
|
||||
@Module({
|
||||
controllers: [ComplianceSettingsController, AuthorityNoticesController],
|
||||
providers: [ComplianceSettingsService, AuthorityNoticesService],
|
||||
exports: [ComplianceSettingsService, AuthorityNoticesService],
|
||||
})
|
||||
export class ComplianceModule {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { AuthorityNoticeKind } from '../../../generated/prisma';
|
||||
|
||||
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||
export const MIN_NOTICE_YEAR = 2000;
|
||||
export const MAX_NOTICE_YEAR = 2100;
|
||||
|
||||
export class CreateAuthorityNoticeDto {
|
||||
@ApiProperty({ enum: AuthorityNoticeKind })
|
||||
@IsEnum(AuthorityNoticeKind)
|
||||
kind!: AuthorityNoticeKind;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nur für die TSK-Zählung (dort Pflicht)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(MIN_NOTICE_YEAR)
|
||||
@Max(MAX_NOTICE_YEAR)
|
||||
year?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2027-01-01',
|
||||
description: 'Vorgabe: heute (Berlin)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
referenceDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '2026-05-01',
|
||||
description:
|
||||
'Gesetzt: die Anzeige entsteht gleich als gesendet (Altbestand)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
sentOn?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
|
||||
import { MAX_NOTICE_TEXT_LENGTH } from './update-authority-notice.dto';
|
||||
|
||||
export class MarkSentDto {
|
||||
@ApiPropertyOptional({
|
||||
example: '2027-01-15',
|
||||
description: 'Vorgabe: heute (Berlin)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
sentOn?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'z. B. E-Mail, Post' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||
sentVia?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Aktenzeichen' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||
reference?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
import { AuthorityNoticeKind } from '../../../generated/prisma';
|
||||
import {
|
||||
MAX_NOTICE_YEAR,
|
||||
MIN_NOTICE_YEAR,
|
||||
} from './create-authority-notice.dto';
|
||||
|
||||
export class NoticeListQuery {
|
||||
@ApiPropertyOptional({ enum: AuthorityNoticeKind })
|
||||
@IsOptional()
|
||||
@IsEnum(AuthorityNoticeKind)
|
||||
kind?: AuthorityNoticeKind;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(MIN_NOTICE_YEAR)
|
||||
@Max(MAX_NOTICE_YEAR)
|
||||
year?: number;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
|
||||
|
||||
export const MAX_NOTICE_TEXT_LENGTH = 300;
|
||||
export const MAX_NOTICE_NOTES_LENGTH = 2000;
|
||||
|
||||
export class UpdateAuthorityNoticeDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Die angezeigte Zahl; bleibt korrigierbar',
|
||||
})
|
||||
@IsOptionalNotNull()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalColonies?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '2027-01-01' })
|
||||
@IsOptionalNotNull()
|
||||
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
referenceDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '2027-01-15' })
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
sentOn?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'z. B. E-Mail, Post' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||
sentVia?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
|
||||
reference?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTICE_NOTES_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ColonyType } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
const MONTH_DAY_PATTERN = /^\d{2}-\d{2}$/;
|
||||
const MONTH_DAY_MESSAGE = 'muss ein Tag im Format MM-TT sein';
|
||||
const MAX_NAME_LENGTH = 200;
|
||||
const MAX_ADDRESS_LENGTH = 300;
|
||||
|
||||
/** One row of the label digit height table. Annahme - vom Inhaber zu prüfen. */
|
||||
export class NetQuantityDigitRuleDto {
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description: 'Obergrenze in g, null = alles darüber',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
maxGrams!: number | null;
|
||||
|
||||
@ApiProperty({ description: 'Mindesthöhe der Ziffern in mm' })
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
minMm!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legal values (deadlines, digit heights) are settings, not code. Every value
|
||||
* here is an assumption the owner has to check.
|
||||
*/
|
||||
export class UpdateComplianceSettingsDto {
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
veterinaryOfficeName?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
veterinaryOfficeAddress?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
registrationNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
noticeOnColonyCountChange?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
noticeOnApiaryChange?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '01-01', description: 'MM-TT, ohne 02-29' })
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@Matches(MONTH_DAY_PATTERN, {
|
||||
message: `tskReferenceDay ${MONTH_DAY_MESSAGE}`,
|
||||
})
|
||||
tskReferenceDay?: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '03-31' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(MONTH_DAY_PATTERN, {
|
||||
message: `tskReportDueDay ${MONTH_DAY_MESSAGE}`,
|
||||
})
|
||||
tskReportDueDay?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
tskMemberNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ enum: ColonyType, isArray: true })
|
||||
@IsOptionalNotNull()
|
||||
@IsArray()
|
||||
@IsEnum(ColonyType, { each: true })
|
||||
tskExcludedColonyTypes?: ColonyType[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NetQuantityDigitRuleDto] })
|
||||
@IsOptionalNotNull()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NetQuantityDigitRuleDto)
|
||||
netQuantityDigitRules?: NetQuantityDigitRuleDto[];
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ColonyType } from '../../generated/prisma';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import {
|
||||
buildHerdSnapshot,
|
||||
herdDeviation,
|
||||
HerdApiaryRow,
|
||||
HerdSnapshot,
|
||||
loadHerdApiaryRows,
|
||||
} from './herd-snapshot';
|
||||
|
||||
const TAKEN_AT = new Date('2027-01-01T10:00:00.000Z');
|
||||
const ENABLED = { noticeOnColonyCountChange: true, noticeOnApiaryChange: true };
|
||||
const P = ColonyType.PRODUCTION_COLONY;
|
||||
const N = ColonyType.NUCLEUS_COLONY;
|
||||
|
||||
function row(
|
||||
apiaryId: string,
|
||||
colonyTypes: ColonyType[],
|
||||
patch: Partial<HerdApiaryRow> = {},
|
||||
): HerdApiaryRow {
|
||||
return {
|
||||
apiaryId,
|
||||
name: `Stand ${apiaryId}`,
|
||||
address: `Weg ${apiaryId}`,
|
||||
location: null,
|
||||
latitude: 48.1,
|
||||
longitude: 11.5,
|
||||
colonyTypes,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildHerdSnapshot', () => {
|
||||
it('counts per apiary and per type', () => {
|
||||
const snapshot = buildHerdSnapshot(
|
||||
[row('a', [P, P, N]), row('b', [P])],
|
||||
TAKEN_AT,
|
||||
);
|
||||
|
||||
expect(snapshot.version).toBe(1);
|
||||
expect(snapshot.takenAt).toBe(TAKEN_AT.toISOString());
|
||||
expect(snapshot.totalLiving).toBe(4);
|
||||
expect(snapshot.apiaries[0]).toMatchObject({
|
||||
apiaryId: 'a',
|
||||
livingColonies: 3,
|
||||
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('excluded colony types do not count', () => {
|
||||
const snapshot = buildHerdSnapshot([row('a', [P, N, N])], TAKEN_AT, [N]);
|
||||
|
||||
expect(snapshot.totalLiving).toBe(1);
|
||||
expect(snapshot.apiaries[0].byType).toEqual({ PRODUCTION_COLONY: 1 });
|
||||
});
|
||||
|
||||
it('keeps an apiary without colonies', () => {
|
||||
const snapshot = buildHerdSnapshot([row('a', [])], TAKEN_AT);
|
||||
|
||||
expect(snapshot.apiaries).toHaveLength(1);
|
||||
expect(snapshot.totalLiving).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadHerdApiaryRows', () => {
|
||||
it('counts only living colonies at active apiaries', async () => {
|
||||
const findMany = jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
address: null,
|
||||
location: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
colonies: [{ colonyType: P }, { colonyType: N }],
|
||||
},
|
||||
]);
|
||||
const db = { apiary: { findMany } };
|
||||
|
||||
const rows = await loadHerdApiaryRows(db as never);
|
||||
|
||||
const args = (findMany.mock.calls as unknown[][])[0][0] as {
|
||||
where: unknown;
|
||||
select: { colonies: { where: unknown } };
|
||||
};
|
||||
expect(args.where).toEqual({ status: 'ACTIVE' });
|
||||
expect(args.select.colonies.where).toEqual(LIVING_COLONY_WHERE);
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ apiaryId: 'a', colonyTypes: [P, N] }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('herdDeviation', () => {
|
||||
const base = [row('a', [P, P]), row('b', [P])];
|
||||
|
||||
function baselineOf(rows: HerdApiaryRow[], totalColonies?: number) {
|
||||
const snapshot = buildHerdSnapshot(rows, TAKEN_AT);
|
||||
return { snapshot, totalColonies: totalColonies ?? snapshot.totalLiving };
|
||||
}
|
||||
|
||||
it('no deviation without changes', () => {
|
||||
const deviation = herdDeviation(
|
||||
baselineOf(base),
|
||||
buildHerdSnapshot(base, TAKEN_AT),
|
||||
ENABLED,
|
||||
);
|
||||
|
||||
expect(deviation).toEqual({
|
||||
triggered: false,
|
||||
colonyCountChanged: false,
|
||||
apiariesAdded: [],
|
||||
apiariesRemoved: [],
|
||||
apiariesRelocated: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('count change triggers only when enabled', () => {
|
||||
const current = buildHerdSnapshot([row('a', [P, P, P]), base[1]], TAKEN_AT);
|
||||
|
||||
expect(herdDeviation(baselineOf(base), current, ENABLED).triggered).toBe(
|
||||
true,
|
||||
);
|
||||
const off = herdDeviation(baselineOf(base), current, {
|
||||
...ENABLED,
|
||||
noticeOnColonyCountChange: false,
|
||||
});
|
||||
expect(off.triggered).toBe(false);
|
||||
expect(off.colonyCountChanged).toBe(true);
|
||||
});
|
||||
|
||||
it('moving between apiaries keeps the total and triggers nothing', () => {
|
||||
const current = buildHerdSnapshot(
|
||||
[row('a', [P]), row('b', [P, P])],
|
||||
TAKEN_AT,
|
||||
);
|
||||
|
||||
const deviation = herdDeviation(baselineOf(base), current, ENABLED);
|
||||
|
||||
expect(deviation.colonyCountChanged).toBe(false);
|
||||
expect(deviation.triggered).toBe(false);
|
||||
});
|
||||
|
||||
it('new, removed or relocated apiary triggers when enabled', () => {
|
||||
const added = buildHerdSnapshot([...base, row('c', [])], TAKEN_AT);
|
||||
const removed = buildHerdSnapshot([base[0]], TAKEN_AT);
|
||||
const relocated = buildHerdSnapshot(
|
||||
[base[0], row('b', [P], { address: 'Neuer Weg 1' })],
|
||||
TAKEN_AT,
|
||||
);
|
||||
const sameCount = (current: HerdSnapshot) =>
|
||||
herdDeviation(baselineOf(base, current.totalLiving), current, ENABLED);
|
||||
|
||||
expect(sameCount(added)).toMatchObject({
|
||||
triggered: true,
|
||||
apiariesAdded: ['c'],
|
||||
});
|
||||
expect(sameCount(removed)).toMatchObject({
|
||||
triggered: true,
|
||||
apiariesRemoved: ['b'],
|
||||
});
|
||||
expect(sameCount(relocated)).toMatchObject({
|
||||
triggered: true,
|
||||
apiariesRelocated: ['b'],
|
||||
});
|
||||
expect(
|
||||
herdDeviation(baselineOf(base, 3), relocated, {
|
||||
...ENABLED,
|
||||
noticeOnApiaryChange: false,
|
||||
}).triggered,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('compares the count with the corrected totalColonies of the baseline', () => {
|
||||
const current = buildHerdSnapshot(base, TAKEN_AT);
|
||||
|
||||
const deviation = herdDeviation(baselineOf(base, 5), current, ENABLED);
|
||||
|
||||
expect(deviation.colonyCountChanged).toBe(true);
|
||||
expect(deviation.triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ColonyType, ComplianceSettings } from '../../generated/prisma';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
|
||||
export const HERD_SNAPSHOT_VERSION = 1;
|
||||
|
||||
export interface HerdApiarySnapshot {
|
||||
apiaryId: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
livingColonies: number;
|
||||
byType: Partial<Record<ColonyType, number>>;
|
||||
}
|
||||
|
||||
export interface HerdSnapshot {
|
||||
version: 1;
|
||||
takenAt: string;
|
||||
apiaries: HerdApiarySnapshot[];
|
||||
totalLiving: number;
|
||||
}
|
||||
|
||||
/** An active apiary with the colony type of each living colony standing there. */
|
||||
export interface HerdApiaryRow {
|
||||
apiaryId: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
location: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
colonyTypes: ColonyType[];
|
||||
}
|
||||
|
||||
export interface HerdDeviation {
|
||||
triggered: boolean;
|
||||
colonyCountChanged: boolean;
|
||||
apiariesAdded: string[];
|
||||
apiariesRemoved: string[];
|
||||
apiariesRelocated: string[];
|
||||
}
|
||||
|
||||
type DeviationSettings = Pick<
|
||||
ComplianceSettings,
|
||||
'noticeOnColonyCountChange' | 'noticeOnApiaryChange'
|
||||
>;
|
||||
|
||||
/** Pure: counts the living colonies per apiary and per type. */
|
||||
export function buildHerdSnapshot(
|
||||
rows: readonly HerdApiaryRow[],
|
||||
takenAt: Date,
|
||||
excludedTypes: readonly ColonyType[] = [],
|
||||
): HerdSnapshot {
|
||||
const apiaries = rows.map((row): HerdApiarySnapshot => {
|
||||
const byType: Partial<Record<ColonyType, number>> = {};
|
||||
let livingColonies = 0;
|
||||
for (const type of row.colonyTypes) {
|
||||
if (excludedTypes.includes(type)) {
|
||||
continue;
|
||||
}
|
||||
byType[type] = (byType[type] ?? 0) + 1;
|
||||
livingColonies += 1;
|
||||
}
|
||||
return {
|
||||
apiaryId: row.apiaryId,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
location: row.location,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
livingColonies,
|
||||
byType,
|
||||
};
|
||||
});
|
||||
return {
|
||||
version: HERD_SNAPSHOT_VERSION,
|
||||
takenAt: takenAt.toISOString(),
|
||||
apiaries,
|
||||
totalLiving: apiaries.reduce(
|
||||
(sum, apiary) => sum + apiary.livingColonies,
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Active apiaries with their living colonies (LIVING_COLONY_WHERE). */
|
||||
export async function loadHerdApiaryRows(
|
||||
db: DbClient,
|
||||
): Promise<HerdApiaryRow[]> {
|
||||
const apiaries = await db.apiary.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
address: true,
|
||||
location: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
colonies: { where: LIVING_COLONY_WHERE, select: { colonyType: true } },
|
||||
},
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return apiaries.map((apiary) => ({
|
||||
apiaryId: apiary.id,
|
||||
name: apiary.name,
|
||||
address: apiary.address,
|
||||
location: apiary.location,
|
||||
latitude: apiary.latitude,
|
||||
longitude: apiary.longitude,
|
||||
colonyTypes: apiary.colonies.map((colony) => colony.colonyType),
|
||||
}));
|
||||
}
|
||||
|
||||
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
|
||||
return (
|
||||
a.address === b.address &&
|
||||
a.location === b.location &&
|
||||
a.latitude === b.latitude &&
|
||||
a.longitude === b.longitude
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the current herd differs from the baseline. The count is compared with
|
||||
* the corrected `totalColonies` of the notice, not with its snapshot. A move
|
||||
* between apiaries keeps the total and triggers nothing.
|
||||
*/
|
||||
export function herdDeviation(
|
||||
baseline: { snapshot: HerdSnapshot; totalColonies: number },
|
||||
current: HerdSnapshot,
|
||||
settings: DeviationSettings,
|
||||
): HerdDeviation {
|
||||
const before = new Map(
|
||||
baseline.snapshot.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
|
||||
);
|
||||
const now = new Map(
|
||||
current.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
|
||||
);
|
||||
const apiariesAdded = [...now.keys()].filter((id) => !before.has(id));
|
||||
const apiariesRemoved = [...before.keys()].filter((id) => !now.has(id));
|
||||
const apiariesRelocated = [...now.entries()]
|
||||
.filter(([id, apiary]) => {
|
||||
const old = before.get(id);
|
||||
return old !== undefined && !sameSite(old, apiary);
|
||||
})
|
||||
.map(([id]) => id);
|
||||
const colonyCountChanged = baseline.totalColonies !== current.totalLiving;
|
||||
const apiaryChanged =
|
||||
apiariesAdded.length + apiariesRemoved.length + apiariesRelocated.length >
|
||||
0;
|
||||
return {
|
||||
triggered:
|
||||
(settings.noticeOnColonyCountChange && colonyCountChanged) ||
|
||||
(settings.noticeOnApiaryChange && apiaryChanged),
|
||||
colonyCountChanged,
|
||||
apiariesAdded,
|
||||
apiariesRemoved,
|
||||
apiariesRelocated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { herdStatusIn } from './herd-status';
|
||||
|
||||
const NOW = new Date('2027-03-01T10:00:00.000Z');
|
||||
|
||||
function settings() {
|
||||
return {
|
||||
id: 'singleton',
|
||||
noticeOnColonyCountChange: true,
|
||||
noticeOnApiaryChange: true,
|
||||
tskExcludedColonyTypes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function dbWith(baseline: unknown) {
|
||||
return {
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue(settings()),
|
||||
upsert: jest.fn().mockResolvedValue(settings()),
|
||||
},
|
||||
authorityNotice: { findFirst: jest.fn().mockResolvedValue(baseline) },
|
||||
apiary: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
address: null,
|
||||
location: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
colonies: [{ colonyType: 'PRODUCTION_COLONY' }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('herdStatusIn', () => {
|
||||
it('no baseline means deviation null, not a herd change', async () => {
|
||||
const status = await herdStatusIn(dbWith(null) as never, NOW);
|
||||
|
||||
expect(status.baseline).toBeNull();
|
||||
expect(status.deviation).toBeNull();
|
||||
expect(status.current.totalLiving).toBe(1);
|
||||
});
|
||||
|
||||
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', async () => {
|
||||
const db = dbWith(null);
|
||||
|
||||
await herdStatusIn(db as never, NOW);
|
||||
|
||||
expect(db.authorityNotice.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'SENT',
|
||||
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
|
||||
},
|
||||
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the deviation against the baseline', async () => {
|
||||
const baseline = {
|
||||
id: 'n1',
|
||||
totalColonies: 3,
|
||||
snapshot: {
|
||||
version: 1,
|
||||
takenAt: NOW.toISOString(),
|
||||
apiaries: [],
|
||||
totalLiving: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const status = await herdStatusIn(dbWith(baseline) as never, NOW);
|
||||
|
||||
expect(status.deviation).toMatchObject({
|
||||
triggered: true,
|
||||
colonyCountChanged: true,
|
||||
apiariesAdded: ['a'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AuthorityNotice, ComplianceSettings } from '../../generated/prisma';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import {
|
||||
buildHerdSnapshot,
|
||||
herdDeviation,
|
||||
HerdDeviation,
|
||||
HerdSnapshot,
|
||||
loadHerdApiaryRows,
|
||||
} from './herd-snapshot';
|
||||
|
||||
export const COMPLIANCE_SETTINGS_ID = 'singleton';
|
||||
|
||||
export interface HerdStatus {
|
||||
settings: ComplianceSettings;
|
||||
baseline: AuthorityNotice | null;
|
||||
current: HerdSnapshot;
|
||||
/** null without a baseline: there is nothing to deviate from. */
|
||||
deviation: HerdDeviation | null;
|
||||
}
|
||||
|
||||
/** The settings row, created with its defaults when it does not exist yet. */
|
||||
export async function complianceSettingsIn(
|
||||
db: DbClient,
|
||||
): Promise<ComplianceSettings> {
|
||||
const existing = await db.complianceSettings.findUnique({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return db.complianceSettings.upsert({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline is the newest SENT registration or herd change (by sentOn);
|
||||
* TSK reports go to another recipient and do not count.
|
||||
*/
|
||||
export function findBaselineIn(db: DbClient): Promise<AuthorityNotice | null> {
|
||||
return db.authorityNotice.findFirst({
|
||||
where: { status: 'SENT', kind: { in: ['REGISTRATION', 'HERD_CHANGE'] } },
|
||||
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function herdStatusIn(
|
||||
db: DbClient,
|
||||
now: Date,
|
||||
): Promise<HerdStatus> {
|
||||
const settings = await complianceSettingsIn(db);
|
||||
const baseline = await findBaselineIn(db);
|
||||
const current = buildHerdSnapshot(await loadHerdApiaryRows(db), now);
|
||||
return {
|
||||
settings,
|
||||
baseline,
|
||||
current,
|
||||
deviation: baseline
|
||||
? herdDeviation(
|
||||
{
|
||||
snapshot: baseline.snapshot as unknown as HerdSnapshot,
|
||||
totalColonies: baseline.totalColonies,
|
||||
},
|
||||
current,
|
||||
settings,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
assertNoActiveRestriction,
|
||||
isRestrictionActive,
|
||||
} from './restricted-zone';
|
||||
|
||||
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
apiary: { name: 'Stand Nord' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTx(rows: unknown[]) {
|
||||
return { apiaryRestriction: { findMany: jest.fn().mockResolvedValue(rows) } };
|
||||
}
|
||||
|
||||
interface HttpBody {
|
||||
code: string;
|
||||
message: string;
|
||||
restrictionIds: string[];
|
||||
restrictions: Record<string, string>[];
|
||||
}
|
||||
|
||||
async function refusal(promise: Promise<unknown>): Promise<HttpBody> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return (error as { getResponse: () => HttpBody }).getResponse();
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
describe('isRestrictionActive', () => {
|
||||
it('active on the start and end day, inactive the day after', () => {
|
||||
const restriction = {
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: day('2026-10-10'),
|
||||
};
|
||||
|
||||
expect(isRestrictionActive(restriction, '2026-09-30')).toBe(false);
|
||||
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
|
||||
expect(isRestrictionActive(restriction, '2026-10-10')).toBe(true);
|
||||
expect(isRestrictionActive(restriction, '2026-10-11')).toBe(false);
|
||||
});
|
||||
|
||||
it('an open end date means until further notice', () => {
|
||||
const restriction = { startsOn: day('2026-10-01'), endsOn: null };
|
||||
|
||||
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
|
||||
expect(isRestrictionActive(restriction, '2040-01-01')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNoActiveRestriction', () => {
|
||||
it('passes without a query when no apiary is named', async () => {
|
||||
const tx = fakeTx([]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, [], '2026-10-15'),
|
||||
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the only restriction ended yesterday', async () => {
|
||||
const tx = fakeTx([row({ endsOn: day('2026-10-14') })]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
|
||||
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||
});
|
||||
|
||||
it('asks for each apiary once, with the unique ids', async () => {
|
||||
const tx = fakeTx([]);
|
||||
|
||||
await assertNoActiveRestriction(
|
||||
tx as never,
|
||||
['a2', 'a1', 'a2'],
|
||||
'2026-10-15',
|
||||
);
|
||||
|
||||
const [[args]] = tx.apiaryRestriction.findMany.mock.calls as [
|
||||
[{ where: { apiaryId: { in: string[] } } }],
|
||||
];
|
||||
expect([...args.where.apiaryId.in].sort()).toEqual(['a1', 'a2']);
|
||||
});
|
||||
|
||||
it('refuses moving into and out of a restricted apiary', async () => {
|
||||
const tx = fakeTx([row()]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a0', 'a1'], '2026-10-15'),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it('the 409 body names apiary, disease and start', async () => {
|
||||
const tx = fakeTx([row({ id: 'r9' })]);
|
||||
|
||||
const body = await refusal(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
|
||||
);
|
||||
|
||||
expect(body.code).toBe('RESTRICTED_ZONE');
|
||||
expect(body.restrictionIds).toEqual(['r9']);
|
||||
expect(body.restrictions).toEqual([
|
||||
{
|
||||
id: 'r9',
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Stand Nord',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2026-10-01',
|
||||
},
|
||||
]);
|
||||
expect(body.message).toContain('Stand Nord');
|
||||
expect(body.message).toContain('Faulbrut');
|
||||
expect(body.message).toContain('01.10.2026');
|
||||
});
|
||||
|
||||
it('override needs compliance:manage and a reason', async () => {
|
||||
const tx = fakeTx([row()]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||
reason: 'Amtstierarzt hat es genehmigt',
|
||||
canOverride: false,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||
reason: 'Amtstierarzt hat es genehmigt',
|
||||
canOverride: true,
|
||||
}),
|
||||
).resolves.toEqual({ overridden: true, restrictionIds: ['r1'] });
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
'a missing reason (%s) is no override even with the right',
|
||||
async (reason) => {
|
||||
const tx = fakeTx([row()]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||
reason,
|
||||
canOverride: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
},
|
||||
);
|
||||
|
||||
it('a reason without an active restriction is ignored, even without the right', async () => {
|
||||
const tx = fakeTx([]);
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
|
||||
reason: 'Amtstierarzt hat es genehmigt',
|
||||
canOverride: false,
|
||||
}),
|
||||
).resolves.toEqual({ overridden: false, restrictionIds: [] });
|
||||
});
|
||||
|
||||
it('refuses the root client', async () => {
|
||||
const root = {
|
||||
$connect: jest.fn(),
|
||||
apiaryRestriction: { findMany: jest.fn() },
|
||||
};
|
||||
|
||||
await expect(
|
||||
assertNoActiveRestriction(root as never, ['a1'], '2026-10-15'),
|
||||
).rejects.toThrow('needs the transaction client');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { dayToDate } from '../common/dates';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||
|
||||
/**
|
||||
* What a caller may do about an active restriction: `reason` is the typed
|
||||
* justification (null and undefined count as omitted), `canOverride` says
|
||||
* whether the user holds compliance:manage.
|
||||
*/
|
||||
export interface RestrictionOverride {
|
||||
reason: string | null | undefined;
|
||||
canOverride: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveRestrictionInfo {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
disease: string;
|
||||
startsOn: string;
|
||||
}
|
||||
|
||||
export interface RestrictionCheck {
|
||||
overridden: boolean;
|
||||
restrictionIds: string[];
|
||||
}
|
||||
|
||||
export const RESTRICTED_ZONE_CODE = 'RESTRICTED_ZONE';
|
||||
|
||||
const NO_RESTRICTION: RestrictionCheck = {
|
||||
overridden: false,
|
||||
restrictionIds: [],
|
||||
};
|
||||
|
||||
function dayOf(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active from the start day to the end day, both included; an open end date
|
||||
* means until further notice. `day` is a Berlin calendar day (YYYY-MM-DD),
|
||||
* the dates are stored at midnight UTC of their day.
|
||||
*/
|
||||
export function isRestrictionActive(
|
||||
restriction: { startsOn: Date; endsOn: Date | null },
|
||||
day: string,
|
||||
): boolean {
|
||||
return (
|
||||
dayOf(restriction.startsOn) <= day &&
|
||||
(restriction.endsOn === null || day <= dayOf(restriction.endsOn))
|
||||
);
|
||||
}
|
||||
|
||||
type RestrictionRow = Prisma.ApiaryRestrictionGetPayload<{
|
||||
include: { apiary: { select: { name: true } } };
|
||||
}>;
|
||||
|
||||
export function toActiveRestrictionInfo(
|
||||
row: RestrictionRow,
|
||||
): ActiveRestrictionInfo {
|
||||
return {
|
||||
id: row.id,
|
||||
apiaryId: row.apiaryId,
|
||||
apiaryName: row.apiary.name,
|
||||
disease: row.disease,
|
||||
startsOn: dayOf(row.startsOn),
|
||||
};
|
||||
}
|
||||
|
||||
/** The restrictions that started on or before `day` (the caller filters the end). */
|
||||
export function startedRestrictions(
|
||||
db: DbClient,
|
||||
apiaryIds: readonly string[] | undefined,
|
||||
day: string,
|
||||
): Promise<RestrictionRow[]> {
|
||||
return db.apiaryRestriction.findMany({
|
||||
where: {
|
||||
apiaryId: apiaryIds ? { in: [...apiaryIds] } : undefined,
|
||||
startsOn: { lte: dayToDate(day) },
|
||||
},
|
||||
include: { apiary: { select: { name: true } } },
|
||||
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
function refusalMessage(infos: readonly ActiveRestrictionInfo[]): string {
|
||||
const places = infos.map(
|
||||
(info) =>
|
||||
`„${info.apiaryName}“ (${info.disease}, seit ${formatDayDe(info.startsOn, true)})`,
|
||||
);
|
||||
return infos.length === 1
|
||||
? `Der Bienenstand ${places[0]} liegt im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`
|
||||
: `Die Bienenstände ${places.join(', ')} liegen im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a colony being brought into, out of or between apiaries while one of
|
||||
* them lies in an active restricted zone (409 RESTRICTED_ZONE). Runs inside the
|
||||
* caller's transaction AFTER the apiary rows are locked (FOR SHARE), so a
|
||||
* restriction cannot start between the check and the write.
|
||||
*
|
||||
* A reason with the right to use it (`canOverride`) lets the action through;
|
||||
* a reason without the right is a 403. A reason without any active
|
||||
* restriction is ignored. The caller writes the reason into audit and
|
||||
* history, never onto the colony.
|
||||
*/
|
||||
export async function assertNoActiveRestriction(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryIds: readonly string[],
|
||||
day: string,
|
||||
override?: RestrictionOverride,
|
||||
): Promise<RestrictionCheck> {
|
||||
assertTransactionClient(tx, 'assertNoActiveRestriction');
|
||||
const ids = [...new Set(apiaryIds)];
|
||||
if (ids.length === 0) {
|
||||
return NO_RESTRICTION;
|
||||
}
|
||||
const active = (await startedRestrictions(tx, ids, day)).filter((row) =>
|
||||
isRestrictionActive(row, day),
|
||||
);
|
||||
if (active.length === 0) {
|
||||
return NO_RESTRICTION;
|
||||
}
|
||||
const restrictionIds = active.map((row) => row.id);
|
||||
if (override?.reason) {
|
||||
if (!override.canOverride) {
|
||||
throw new ForbiddenException(
|
||||
'Für eine Ausnahme vom Sperrbezirk fehlt das Recht Prüfbereitschaft verwalten (compliance:manage).',
|
||||
);
|
||||
}
|
||||
return { overridden: true, restrictionIds };
|
||||
}
|
||||
const restrictions = active.map(toActiveRestrictionInfo);
|
||||
throw new ConflictException({
|
||||
statusCode: 409,
|
||||
code: RESTRICTED_ZONE_CODE,
|
||||
message: refusalMessage(restrictions),
|
||||
restrictionIds,
|
||||
restrictions,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isValidMonthDay, tskReferenceDayOf } from './tsk-day';
|
||||
|
||||
describe('isValidMonthDay', () => {
|
||||
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd', '01-32', ''])(
|
||||
'rejects %s',
|
||||
(value) => {
|
||||
expect(isValidMonthDay(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['01-01', '12-31', '02-28', '04-30'])('accepts %s', (value) => {
|
||||
expect(isValidMonthDay(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tskReferenceDayOf', () => {
|
||||
it('joins year and month-day into a day', () => {
|
||||
expect(tskReferenceDayOf(2027, '01-01')).toBe('2027-01-01');
|
||||
expect(tskReferenceDayOf(2028, '12-31')).toBe('2028-12-31');
|
||||
});
|
||||
|
||||
it('rejects an invalid month-day', () => {
|
||||
expect(() => tskReferenceDayOf(2027, '02-29')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const MONTH_DAY_PATTERN = /^(\d{2})-(\d{2})$/;
|
||||
// Days per month of a non-leap year: 02-29 is not a valid reference day, so
|
||||
// the reference day exists in every year.
|
||||
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
/** MM-TT with a real month and a day that exists in a non-leap year. */
|
||||
export function isValidMonthDay(value: string): boolean {
|
||||
const match = MONTH_DAY_PATTERN.exec(value);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const month = Number(match[1]);
|
||||
const day = Number(match[2]);
|
||||
return (
|
||||
month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1]
|
||||
);
|
||||
}
|
||||
|
||||
/** The reference day (YYYY-MM-DD) of a year for a stored MM-TT. */
|
||||
export function tskReferenceDayOf(year: number, monthDay: string): string {
|
||||
if (!isValidMonthDay(monthDay)) {
|
||||
throw new Error(`Invalid month-day: ${monthDay}`);
|
||||
}
|
||||
return `${String(year).padStart(4, '0')}-${monthDay}`;
|
||||
}
|
||||
@@ -1,14 +1,27 @@
|
||||
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@ApiTags('export')
|
||||
@ApiBearerAuth()
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
constructor(private readonly exportService: ExportService) {}
|
||||
constructor(
|
||||
private readonly exportService: ExportService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('export:read')
|
||||
@Get('colonies.csv')
|
||||
@@ -54,8 +67,17 @@ export class ExportController {
|
||||
@Query('colonyId') colonyId: string | undefined,
|
||||
@Query('apiaryId') apiaryId: string | undefined,
|
||||
@Query('year') year: string | undefined,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
// The Bestandsbuch prints supplier, purchase proofs and the vet: those
|
||||
// are visible with veterinary-medicines:read only.
|
||||
if (report === 'treatment-log') {
|
||||
const held = await this.permissions.getPermissions(actor.id);
|
||||
if (!held.has('veterinary-medicines:read')) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
const pdf = await this.exportService.exportReportPdf(report, {
|
||||
honeyBatchId,
|
||||
colonyId,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
apiaryOnDate,
|
||||
groupTreatmentLog,
|
||||
LOT_FOOTNOTE,
|
||||
MISSING,
|
||||
resolveSourceValues,
|
||||
treatmentLogCells,
|
||||
treatmentLogGaps,
|
||||
TreatmentLogSource,
|
||||
} from './treatment-log';
|
||||
|
||||
@@ -24,10 +29,51 @@ function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
|
||||
prescribingVet: null,
|
||||
withdrawalPeriodDays: 0,
|
||||
administeredBy: 'Imker',
|
||||
medicineId: null,
|
||||
prescriptionOnly: false,
|
||||
doseQuantity: null,
|
||||
medicineUnit: null,
|
||||
lot: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type Lot = NonNullable<TreatmentLogSource['lot']>;
|
||||
|
||||
const lotOf = (overrides: Partial<Lot> = {}): Lot => ({
|
||||
id: 'lot-1',
|
||||
batchNumber: 'LOT-B',
|
||||
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||
purchaseProof: 'Rechnung 77',
|
||||
prescribingVet: 'Dr. Vet',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A treatment from a pack: supplier, proof, batch and vet only on the lot.
|
||||
const fromLot = (
|
||||
overrides: Partial<TreatmentLogSource> = {},
|
||||
): TreatmentLogSource =>
|
||||
source({
|
||||
medicineId: 'med-1',
|
||||
batchNumber: null,
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
prescribingVet: null,
|
||||
lot: lotOf(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const withDose = (
|
||||
dose: number,
|
||||
unit: 'ML' | 'G' | 'PIECE',
|
||||
): TreatmentLogSource =>
|
||||
source({
|
||||
amount: null,
|
||||
medicineId: 'med-1',
|
||||
doseQuantity: dose,
|
||||
medicineUnit: unit,
|
||||
});
|
||||
|
||||
describe('apiaryOnDate', () => {
|
||||
it('uses the apiary stored with the treatment', () => {
|
||||
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
|
||||
@@ -124,3 +170,178 @@ describe('groupTreatmentLog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceValues', () => {
|
||||
it('takes supplier, proof, batch and vet from the lot', () => {
|
||||
expect(
|
||||
resolveSourceValues(fromLot({ batchNumber: 'ignored-by-design' })),
|
||||
).toMatchObject({
|
||||
batchNumber: 'LOT-B',
|
||||
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||
purchaseProof: 'Rechnung 77',
|
||||
prescribingVet: 'Dr. Vet',
|
||||
});
|
||||
});
|
||||
|
||||
it('never mixes treatment text into a row with a lot', () => {
|
||||
const values = resolveSourceValues(
|
||||
fromLot({
|
||||
supplier: 'alt',
|
||||
lot: lotOf({ supplier: null, purchaseProof: null }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(values.supplier).toBeNull();
|
||||
expect(values.purchaseProof).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the treatment text when there is no lot', () => {
|
||||
expect(resolveSourceValues(source({}))).toMatchObject({
|
||||
batchNumber: 'B1',
|
||||
supplier: 'Imkereibedarf X',
|
||||
purchaseProof: 'Rechnung 1',
|
||||
prescribingVet: null,
|
||||
amount: '30 ml',
|
||||
});
|
||||
});
|
||||
|
||||
it('prints the dose with unit when the amount text is empty', () => {
|
||||
expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml');
|
||||
expect(resolveSourceValues(withDose(2.5, 'G')).amount).toBe('2,5 g');
|
||||
expect(resolveSourceValues(withDose(1, 'PIECE')).amount).toBe('1 Stück');
|
||||
});
|
||||
|
||||
it('prefers the amount text over the dose', () => {
|
||||
expect(
|
||||
resolveSourceValues({ ...withDose(30, 'ML'), amount: '5 ml je Volk' })
|
||||
.amount,
|
||||
).toBe('5 ml je Volk');
|
||||
});
|
||||
|
||||
it('leaves the amount empty without text and without dose', () => {
|
||||
expect(resolveSourceValues(source({ amount: null })).amount).toBeNull();
|
||||
expect(resolveSourceValues(source({ amount: ' ' })).amount).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('treatmentLogGaps', () => {
|
||||
const complete = {
|
||||
supplier: 'S',
|
||||
purchaseProof: 'P',
|
||||
amount: '1 ml',
|
||||
withdrawalPeriodDays: 0,
|
||||
prescribingVet: null,
|
||||
prescriptionOnly: false,
|
||||
};
|
||||
|
||||
it('reports nothing for a complete row; a withdrawal of 0 days is complete', () => {
|
||||
expect(treatmentLogGaps(complete)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports each missing mandatory field', () => {
|
||||
expect(
|
||||
treatmentLogGaps({
|
||||
...complete,
|
||||
supplier: null,
|
||||
purchaseProof: '',
|
||||
amount: null,
|
||||
withdrawalPeriodDays: null,
|
||||
}),
|
||||
).toEqual(['SUPPLIER', 'PROOF', 'AMOUNT', 'WITHDRAWAL']);
|
||||
});
|
||||
|
||||
it('requires the vet only for prescription-only medicines', () => {
|
||||
expect(treatmentLogGaps({ ...complete, prescriptionOnly: true })).toEqual([
|
||||
'VET',
|
||||
]);
|
||||
expect(
|
||||
treatmentLogGaps({
|
||||
...complete,
|
||||
prescriptionOnly: true,
|
||||
prescribingVet: 'Dr. Vet',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(treatmentLogGaps(complete)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bestandsbuch from the lot', () => {
|
||||
it('groups two colonies of the same lot and day into one row', () => {
|
||||
const [sheet] = groupTreatmentLog(
|
||||
[
|
||||
fromLot({ id: '1', colonyCode: 'V-02', apiaryId: 'a' }),
|
||||
fromLot({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(sheet.rows).toHaveLength(1);
|
||||
expect(sheet.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
|
||||
expect(sheet.rows[0]).toMatchObject({
|
||||
lotId: 'lot-1',
|
||||
supplier: 'Apotheke Y, Hauptstr. 1',
|
||||
batchNumber: 'LOT-B',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps colonies of two different lots in two rows', () => {
|
||||
const [sheet] = groupTreatmentLog(
|
||||
[
|
||||
fromLot({ id: '1', apiaryId: 'a' }),
|
||||
fromLot({
|
||||
id: '2',
|
||||
colonyId: 'c2',
|
||||
apiaryId: 'a',
|
||||
lot: lotOf({ id: 'lot-2' }),
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(sheet.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('prints FEHLT exactly where treatmentLogGaps reports a gap', () => {
|
||||
const variants: Partial<TreatmentLogSource>[] = [
|
||||
{},
|
||||
{ lot: lotOf({ supplier: null }) },
|
||||
{ lot: lotOf({ purchaseProof: '' }) },
|
||||
{ amount: null },
|
||||
{ withdrawalPeriodDays: null },
|
||||
{ prescriptionOnly: true, lot: lotOf({ prescribingVet: null }) },
|
||||
{ prescriptionOnly: true },
|
||||
{
|
||||
lot: lotOf({ supplier: null, purchaseProof: null }),
|
||||
amount: null,
|
||||
withdrawalPeriodDays: null,
|
||||
prescriptionOnly: true,
|
||||
},
|
||||
];
|
||||
for (const overrides of variants) {
|
||||
const [sheet] = groupTreatmentLog(
|
||||
[fromLot({ apiaryId: 'a', ...overrides })],
|
||||
[],
|
||||
);
|
||||
const row = sheet.rows[0];
|
||||
const gaps = treatmentLogGaps(row);
|
||||
const cells = treatmentLogCells(row);
|
||||
const [, , supplierCell, proofCell, , amountCell, withdrawalCell] = cells;
|
||||
|
||||
expect(supplierCell.startsWith(MISSING)).toBe(gaps.includes('SUPPLIER'));
|
||||
expect(supplierCell.includes(`Tierarzt: ${MISSING}`)).toBe(
|
||||
gaps.includes('VET'),
|
||||
);
|
||||
expect(proofCell === MISSING).toBe(gaps.includes('PROOF'));
|
||||
expect(amountCell === MISSING).toBe(gaps.includes('AMOUNT'));
|
||||
expect(withdrawalCell === MISSING).toBe(gaps.includes('WITHDRAWAL'));
|
||||
// No FEHLT anywhere else.
|
||||
expect(cells.join('|').split(MISSING).length - 1).toBe(gaps.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('footnote says supplier, proof and batch come from the recorded lot', () => {
|
||||
expect(LOT_FOOTNOTE).toBe(
|
||||
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import type { MedicineUnit } from '../../generated/prisma';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
@@ -24,6 +25,15 @@ import {
|
||||
|
||||
export const MISSING = 'FEHLT';
|
||||
|
||||
export const LOT_FOOTNOTE =
|
||||
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.';
|
||||
|
||||
const UNIT_LABELS: Record<MedicineUnit, string> = {
|
||||
ML: 'ml',
|
||||
G: 'g',
|
||||
PIECE: 'Stück',
|
||||
};
|
||||
|
||||
export interface TreatmentLogSource {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
@@ -42,6 +52,19 @@ export interface TreatmentLogSource {
|
||||
prescribingVet: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
administeredBy: string;
|
||||
medicineId: string | null;
|
||||
/** Of the catalogue entry; the vet is only mandatory for these. */
|
||||
prescriptionOnly: boolean;
|
||||
doseQuantity: number | null;
|
||||
medicineUnit: MedicineUnit | null;
|
||||
/** The pack the dose was taken from: its values replace the treatment's. */
|
||||
lot: {
|
||||
id: string;
|
||||
batchNumber: string | null;
|
||||
supplier: string | null;
|
||||
purchaseProof: string | null;
|
||||
prescribingVet: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ColonyMove {
|
||||
@@ -65,6 +88,76 @@ export interface TreatmentLogRow {
|
||||
administeredBy: string;
|
||||
/** Apiary derived from today's assignment, not known for that date. */
|
||||
locationUncertain: boolean;
|
||||
lotId: string | null;
|
||||
prescriptionOnly: boolean;
|
||||
}
|
||||
|
||||
export type TreatmentLogGap =
|
||||
'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
|
||||
|
||||
function hasText(value: string | null | undefined): value is string {
|
||||
return value !== null && value !== undefined && value.trim() !== '';
|
||||
}
|
||||
|
||||
function orNull(value: string | null | undefined): string | null {
|
||||
return hasText(value) ? value : null;
|
||||
}
|
||||
|
||||
function doseText(dose: number, unit: MedicineUnit): string {
|
||||
return `${String(dose).replace('.', ',')} ${UNIT_LABELS[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns of one treatment, each from exactly one source: the pack when
|
||||
* the dose was taken from one (supplier, proof, batch number and vet are never
|
||||
* mixed with text on the treatment), else the treatment itself. The amount is
|
||||
* the treatment's own text, else its dose with the unit of the medicine.
|
||||
*/
|
||||
export function resolveSourceValues(
|
||||
source: TreatmentLogSource,
|
||||
): Pick<
|
||||
TreatmentLogSource,
|
||||
'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'
|
||||
> & { amount: string | null } {
|
||||
const origin = source.lot ?? source;
|
||||
const amount = hasText(source.amount)
|
||||
? source.amount
|
||||
: source.doseQuantity !== null && source.medicineUnit !== null
|
||||
? doseText(source.doseQuantity, source.medicineUnit)
|
||||
: null;
|
||||
return {
|
||||
batchNumber: orNull(origin.batchNumber),
|
||||
supplier: orNull(origin.supplier),
|
||||
purchaseProof: orNull(origin.purchaseProof),
|
||||
prescribingVet: orNull(origin.prescribingVet),
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The mandatory fields a row lacks: supplier, proof, amount and withdrawal
|
||||
* period (0 days counts as given), and the vet only for prescription-only
|
||||
* medicines. The PDF prints FEHLT exactly where this reports a gap, and the
|
||||
* readiness check reads the same list.
|
||||
*/
|
||||
export function treatmentLogGaps(
|
||||
row: Pick<
|
||||
TreatmentLogRow,
|
||||
| 'supplier'
|
||||
| 'purchaseProof'
|
||||
| 'amount'
|
||||
| 'withdrawalPeriodDays'
|
||||
| 'prescribingVet'
|
||||
| 'prescriptionOnly'
|
||||
>,
|
||||
): TreatmentLogGap[] {
|
||||
const gaps: TreatmentLogGap[] = [];
|
||||
if (!hasText(row.supplier)) gaps.push('SUPPLIER');
|
||||
if (!hasText(row.purchaseProof)) gaps.push('PROOF');
|
||||
if (!hasText(row.amount)) gaps.push('AMOUNT');
|
||||
if (row.withdrawalPeriodDays === null) gaps.push('WITHDRAWAL');
|
||||
if (row.prescriptionOnly && !hasText(row.prescribingVet)) gaps.push('VET');
|
||||
return gaps;
|
||||
}
|
||||
|
||||
export interface TreatmentLogSheet {
|
||||
@@ -120,17 +213,20 @@ export function groupTreatmentLog(
|
||||
for (const source of sorted) {
|
||||
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
|
||||
const start = source.startDate ?? source.date;
|
||||
const values = resolveSourceValues(source);
|
||||
const key = [
|
||||
source.date.toISOString(),
|
||||
source.endDate?.toISOString() ?? '',
|
||||
source.product,
|
||||
source.batchNumber ?? '',
|
||||
source.amount ?? '',
|
||||
source.supplier ?? '',
|
||||
source.purchaseProof ?? '',
|
||||
source.prescribingVet ?? '',
|
||||
values.batchNumber ?? '',
|
||||
values.amount ?? '',
|
||||
values.supplier ?? '',
|
||||
values.purchaseProof ?? '',
|
||||
values.prescribingVet ?? '',
|
||||
source.withdrawalPeriodDays ?? '',
|
||||
source.administeredBy,
|
||||
source.lot?.id ?? '',
|
||||
source.prescriptionOnly,
|
||||
uncertain,
|
||||
].join('|');
|
||||
|
||||
@@ -142,17 +238,19 @@ export function groupTreatmentLog(
|
||||
rows.set(key, {
|
||||
colonyCodes: [source.colonyCode],
|
||||
product: source.product,
|
||||
batchNumber: source.batchNumber,
|
||||
supplier: source.supplier,
|
||||
prescribingVet: source.prescribingVet,
|
||||
purchaseProof: source.purchaseProof,
|
||||
batchNumber: values.batchNumber,
|
||||
supplier: values.supplier,
|
||||
prescribingVet: values.prescribingVet,
|
||||
purchaseProof: values.purchaseProof,
|
||||
date: source.date,
|
||||
endDate: source.endDate,
|
||||
durationDays: durationDays(start, source.endDate),
|
||||
amount: source.amount,
|
||||
amount: values.amount,
|
||||
withdrawalPeriodDays: source.withdrawalPeriodDays,
|
||||
administeredBy: source.administeredBy,
|
||||
locationUncertain: uncertain,
|
||||
lotId: source.lot?.id ?? null,
|
||||
prescriptionOnly: source.prescriptionOnly,
|
||||
});
|
||||
}
|
||||
sheets.set(apiaryId, rows);
|
||||
@@ -194,6 +292,16 @@ export async function buildTreatmentLog(
|
||||
include: {
|
||||
colony: { select: { code: true, apiaryId: true } },
|
||||
user: { select: { name: true } },
|
||||
medicine: { select: { prescriptionOnly: true, unit: true } },
|
||||
medicineLot: {
|
||||
select: {
|
||||
id: true,
|
||||
batchNumber: true,
|
||||
supplier: true,
|
||||
purchaseProof: true,
|
||||
prescribingVet: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
|
||||
@@ -226,6 +334,11 @@ export async function buildTreatmentLog(
|
||||
prescribingVet: t.prescribingVet,
|
||||
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
||||
administeredBy: t.user.name,
|
||||
medicineId: t.medicineId,
|
||||
prescriptionOnly: t.medicine?.prescriptionOnly ?? false,
|
||||
doseQuantity: t.doseQuantity,
|
||||
medicineUnit: t.medicine?.unit ?? null,
|
||||
lot: t.medicineLot,
|
||||
})),
|
||||
moves,
|
||||
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
|
||||
@@ -255,6 +368,30 @@ function orMissing(value: string | number | null | undefined): string {
|
||||
: String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The printed cells of one row. FEHLT stands exactly where treatmentLogGaps
|
||||
* reports a gap - the supplier column also carries the vet, with FEHLT after
|
||||
* "Tierarzt:" when one is required and missing.
|
||||
*/
|
||||
export function treatmentLogCells(row: TreatmentLogRow): string[] {
|
||||
const gaps = treatmentLogGaps(row);
|
||||
const vetPart = row.prescribingVet
|
||||
? `; Tierarzt: ${row.prescribingVet}`
|
||||
: gaps.includes('VET')
|
||||
? `; Tierarzt: ${MISSING}`
|
||||
: '';
|
||||
return [
|
||||
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
|
||||
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
|
||||
`${gaps.includes('SUPPLIER') ? MISSING : row.supplier}${vetPart}`,
|
||||
gaps.includes('PROOF') ? MISSING : (row.purchaseProof ?? MISSING),
|
||||
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
|
||||
gaps.includes('AMOUNT') ? MISSING : (row.amount ?? MISSING),
|
||||
gaps.includes('WITHDRAWAL') ? MISSING : orMissing(row.withdrawalPeriodDays),
|
||||
orDash(row.administeredBy),
|
||||
];
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
doc: PDFKit.PDFDocument,
|
||||
log: TreatmentLog,
|
||||
@@ -305,16 +442,7 @@ function renderSheet(
|
||||
{ header: 'Wartezeit (Tage)', width: 50 },
|
||||
{ header: 'Anwendende Person', width: 0 },
|
||||
]),
|
||||
sheet.rows.map((row) => [
|
||||
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
|
||||
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
|
||||
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
|
||||
orMissing(row.purchaseProof),
|
||||
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
|
||||
orMissing(row.amount),
|
||||
orMissing(row.withdrawalPeriodDays),
|
||||
orDash(row.administeredBy),
|
||||
]),
|
||||
sheet.rows.map(treatmentLogCells),
|
||||
);
|
||||
doc.font('Helvetica').fontSize(7);
|
||||
if (uncertain) {
|
||||
@@ -324,7 +452,7 @@ function renderSheet(
|
||||
);
|
||||
}
|
||||
doc.text(
|
||||
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
|
||||
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung oder in der Packung nachtragen. ${LOT_FOOTNOTE} Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
|
||||
PDF_MARGIN,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
Equals,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PLACEHOLDER_KEYS } from '../label-element.types';
|
||||
|
||||
const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const;
|
||||
const TEXT_ALIGNS = ['left', 'center', 'right'] as const;
|
||||
const MAX_ELEMENTS = 100;
|
||||
const MAX_FONT_SIZE_PT = 500;
|
||||
// A label is a few centimetres; the bounds only keep absurd numbers out.
|
||||
const MAX_COORDINATE_MM = 1000;
|
||||
const MIN_BOX_MM = 0.1;
|
||||
const MAX_SHEET_MM = 1000;
|
||||
|
||||
/**
|
||||
* A text element of a label draft. The check only looks at text, so every
|
||||
* other element type is refused here instead of being ignored: a draft that
|
||||
* sends an image is a client bug worth a 400.
|
||||
*/
|
||||
export class LabelTextElementDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enum: ['text'] })
|
||||
@Equals('text')
|
||||
type!: 'text';
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(-MAX_COORDINATE_MM)
|
||||
@Max(MAX_COORDINATE_MM)
|
||||
x!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(-MAX_COORDINATE_MM)
|
||||
@Max(MAX_COORDINATE_MM)
|
||||
y!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(MIN_BOX_MM)
|
||||
@Max(MAX_SHEET_MM)
|
||||
width!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(MIN_BOX_MM)
|
||||
@Max(MAX_SHEET_MM)
|
||||
height!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Literaler Text' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PLACEHOLDER_KEYS })
|
||||
@IsOptional()
|
||||
@IsIn(PLACEHOLDER_KEYS)
|
||||
placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null;
|
||||
|
||||
@ApiProperty({ description: 'Schriftgröße in pt' })
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
@Max(MAX_FONT_SIZE_PT)
|
||||
fontSize!: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: FONT_FAMILIES })
|
||||
@IsOptional()
|
||||
@IsIn(FONT_FAMILIES)
|
||||
fontFamily?: (typeof FONT_FAMILIES)[number];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
bold?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
italic?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TEXT_ALIGNS })
|
||||
@IsOptional()
|
||||
@IsIn(TEXT_ALIGNS)
|
||||
align?: (typeof TEXT_ALIGNS)[number];
|
||||
}
|
||||
|
||||
/** POST /label-templates/check: a draft that is not saved yet. */
|
||||
export class CheckLabelTemplateDto {
|
||||
@ApiProperty({ type: [LabelTextElementDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_ELEMENTS)
|
||||
@ArrayUnique((element: LabelTextElementDto) => element.id)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabelTextElementDto)
|
||||
elements!: LabelTextElementDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Wird geprüft, fließt aber nicht in die Prüfung ein.',
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
@Max(MAX_SHEET_MM)
|
||||
widthMm!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(10)
|
||||
@Max(MAX_SHEET_MM)
|
||||
heightMm!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Kleinste x-Höhe (mm) des Entwurfs, 0,5 bis 5.',
|
||||
})
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
@Max(5)
|
||||
minXHeightMm!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Abfüllung, gegen die geprüft wird. Leer = Beispielwerte.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||
bottlingBatchId?: string;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
expectedParticulars,
|
||||
loadDigitRules,
|
||||
parseDigitRules,
|
||||
} from './label-check.loader';
|
||||
|
||||
describe('expectedParticulars', () => {
|
||||
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', () => {
|
||||
const expected = expectedParticulars(
|
||||
{
|
||||
jarSize: 500,
|
||||
lotNumber: '2026-0042',
|
||||
// Midnight UTC: stays on its own calendar day.
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
},
|
||||
);
|
||||
|
||||
expect(expected).toEqual({
|
||||
jarSizeGrams: 500,
|
||||
bestBefore: '2028-06-01',
|
||||
lotNumber: '2026-0042',
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the best-before day from labelDayOf, an instant maps to its Berlin day', () => {
|
||||
const expected = expectedParticulars(
|
||||
{
|
||||
jarSize: 250,
|
||||
lotNumber: 'L1',
|
||||
bestBeforeDate: new Date('2028-06-01T22:30:00.000Z'),
|
||||
},
|
||||
{
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(expected.bestBefore).toBe('2028-06-02');
|
||||
expect(expected.foodBusinessName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDigitRules', () => {
|
||||
const open = { maxGrams: null, minMm: 5 };
|
||||
|
||||
it('reads the stored rules', () => {
|
||||
const rules = [{ maxGrams: 100, minMm: 2 }, open];
|
||||
expect(parseDigitRules(rules)).toEqual(rules);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['a string', 'x'],
|
||||
['an object', {}],
|
||||
['an empty array', []],
|
||||
['a non-numeric limit', [{ maxGrams: 'a', minMm: 2 }, open]],
|
||||
['an empty rule', [{}]],
|
||||
['minMm zero', [{ maxGrams: 100, minMm: 0 }, open]],
|
||||
['minMm negative', [{ maxGrams: 100, minMm: -1 }, open]],
|
||||
['minMm not finite', [{ maxGrams: 100, minMm: Infinity }, open]],
|
||||
['maxGrams not finite', [{ maxGrams: Infinity, minMm: 2 }, open]],
|
||||
['no rule without a limit', [{ maxGrams: 100, minMm: 2 }]],
|
||||
['two rules without a limit', [open, { maxGrams: null, minMm: 6 }]],
|
||||
])('rejects %s', (_name, value) => {
|
||||
expect(parseDigitRules(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDigitRules', () => {
|
||||
let errorLog: jest.SpyInstance;
|
||||
const dbWith = (row: unknown) => ({
|
||||
complianceSettings: { findUnique: jest.fn().mockResolvedValue(row) },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
errorLog = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
async function refusal(row: unknown): Promise<ConflictException> {
|
||||
try {
|
||||
await loadDigitRules(dbWith(row) as never);
|
||||
} catch (error) {
|
||||
return error as ConflictException;
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
it('fails closed with 409 when the settings row is missing', async () => {
|
||||
const error = await refusal(null);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse()).toEqual({
|
||||
code: 'COMPLIANCE_SETTINGS_INVALID',
|
||||
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||
});
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed with 409 when the stored rules are invalid', async () => {
|
||||
const error = await refusal({ netQuantityDigitRules: [{ minMm: 0 }] });
|
||||
expect(error.getStatus()).toBe(409);
|
||||
});
|
||||
|
||||
it('uses the rules of the settings row', async () => {
|
||||
const rules = [{ maxGrams: null, minMm: 7 }];
|
||||
const db = dbWith({ netQuantityDigitRules: rules });
|
||||
await expect(loadDigitRules(db as never)).resolves.toEqual(rules);
|
||||
expect(db.complianceSettings.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
select: { netQuantityDigitRules: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { LabelCheckInput, NetQuantityDigitRule } from './label-particulars';
|
||||
import { labelDayOf } from './label-text';
|
||||
|
||||
const COMPLIANCE_SETTINGS_ID = 'singleton';
|
||||
export const COMPLIANCE_SETTINGS_INVALID = 'COMPLIANCE_SETTINGS_INVALID';
|
||||
const logger = new Logger('LabelCheckLoader');
|
||||
|
||||
export interface BottlingForCheck {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
bestBeforeDate: Date;
|
||||
}
|
||||
|
||||
export interface MasterDataForCheck {
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the label must show for this bottling. The best-before day comes from
|
||||
* `labelDayOf`, the function the printed text uses too, so check and print
|
||||
* cannot disagree about the day.
|
||||
*/
|
||||
export function expectedParticulars(
|
||||
bottling: BottlingForCheck,
|
||||
settings: MasterDataForCheck,
|
||||
): LabelCheckInput['expected'] {
|
||||
return {
|
||||
jarSizeGrams: bottling.jarSize,
|
||||
bestBefore: labelDayOf(bottling.bestBeforeDate),
|
||||
lotNumber: bottling.lotNumber,
|
||||
originCountry: settings.originCountry,
|
||||
foodBusinessName: settings.foodBusinessName,
|
||||
foodBusinessAddress: settings.foodBusinessAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function isDigitRule(value: unknown): value is NetQuantityDigitRule {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const { maxGrams, minMm } = value as Record<string, unknown>;
|
||||
return (
|
||||
(maxGrams === null ||
|
||||
(typeof maxGrams === 'number' && Number.isFinite(maxGrams))) &&
|
||||
typeof minMm === 'number' &&
|
||||
Number.isFinite(minMm)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored JSON as rules, or null when it cannot be trusted: a rule needs
|
||||
* a finite positive height, the list must not be empty, and exactly one rule
|
||||
* (the catch-all for any size) has no upper limit.
|
||||
*/
|
||||
export function parseDigitRules(value: unknown): NetQuantityDigitRule[] | null {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
!value.every(isDigitRule)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rules = value.map(({ maxGrams, minMm }) => ({ maxGrams, minMm }));
|
||||
const open = rules.filter((rule) => rule.maxGrams === null).length;
|
||||
return open === 1 && rules.every((rule) => rule.minMm > 0) ? rules : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The digit rules of the compliance settings. No legal value lives in the
|
||||
* code: a missing row or unusable rules refuse the check (409) instead of
|
||||
* checking against a guess.
|
||||
*/
|
||||
export async function loadDigitRules(
|
||||
db: DbClient,
|
||||
): Promise<NetQuantityDigitRule[]> {
|
||||
const row = await db.complianceSettings.findUnique({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
select: { netQuantityDigitRules: true },
|
||||
});
|
||||
const rules = parseDigitRules(row?.netQuantityDigitRules);
|
||||
if (!rules) {
|
||||
const { message } = loggableError(
|
||||
new Error(
|
||||
row
|
||||
? 'Invalid netQuantityDigitRules'
|
||||
: 'ComplianceSettings row missing',
|
||||
),
|
||||
);
|
||||
logger.error(message);
|
||||
throw new ConflictException({
|
||||
code: COMPLIANCE_SETTINGS_INVALID,
|
||||
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
|
||||
});
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const FORBIDDEN = new RegExp(
|
||||
'toLocaleTimeString\\(',
|
||||
'toLocaleDateString\\((?![^)]*timeZone)',
|
||||
'toLocaleString\\((?![^)]*timeZone)',
|
||||
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
'Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
// new Date(y, m, d, ...): several arguments build a local-time date
|
||||
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
||||
].join('|'),
|
||||
@@ -86,6 +86,7 @@ describe('local-time guard', () => {
|
||||
'date.toDateString()',
|
||||
"date.toLocaleDateString('de-DE')",
|
||||
"new Intl.DateTimeFormat('de-DE')",
|
||||
"Intl.DateTimeFormat('de-DE')",
|
||||
'new Date(2027, 2, 1)',
|
||||
])('flags %s', (sample) => {
|
||||
expect(sample).toMatch(FORBIDDEN);
|
||||
|
||||
@@ -735,23 +735,57 @@ describe('checkLabel: round 3', () => {
|
||||
});
|
||||
|
||||
it('measures the distance against the whole group, not the last element', () => {
|
||||
// a tall street box, an empty box below it, the town further down: the
|
||||
// empty box alone would be 24 mm from the town, the street box is not
|
||||
// A is a tall box, B a short one below its top, C lies 24 mm below B but
|
||||
// inside A's box: only the running extent of the group joins C
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, {
|
||||
text: 'Bühlweg 10',
|
||||
height: 40,
|
||||
}),
|
||||
textElement('empty', null, 64, { text: '', height: 2 }),
|
||||
textElement('town', null, 90, {
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('a', null, 62, { text: 'Bühlweg', height: 40 }),
|
||||
textElement('b', null, 64, { x: 30, text: '10', height: 2 }),
|
||||
textElement('c', null, 90, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('an empty box does not bridge distances', () => {
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||
textElement('empty', null, 66, { text: '', height: 40 }),
|
||||
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a tall text box bridges rows by its box height (documented)', () => {
|
||||
const street = (height: number) => [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height }),
|
||||
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements: street(40) })).gaps).toEqual([]);
|
||||
expect(codes(checkLabel(input({ elements: street(5) })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names only clipped parts that belong to the match', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) =>
|
||||
element.id === 'town' || element.id === 'lot-ref' ? 999 : 0,
|
||||
};
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
|
||||
textElement('town', null, 68, { text: '86450 Altenmünster', height: 5 }),
|
||||
textElement('lot-ref', null, 90, { text: 'Lot 10', height: 5 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer }));
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.gaps[0].elementIds).toEqual(['town']);
|
||||
});
|
||||
|
||||
describe('number and unit in two elements', () => {
|
||||
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
||||
return [
|
||||
@@ -792,6 +826,38 @@ describe('checkLabel: round 3', () => {
|
||||
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
||||
});
|
||||
|
||||
it('finds a big number with a small, bottom-aligned unit', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
height: 10,
|
||||
fontSize: 20,
|
||||
text: '500',
|
||||
}),
|
||||
textElement('unit', null, 20, {
|
||||
x: 24,
|
||||
width: 10,
|
||||
height: 4,
|
||||
fontSize: 10,
|
||||
text: 'g',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not pair a far left number with a far right unit', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { x: 2, width: 20, text: '500' }),
|
||||
textElement('unit', null, 14, { x: 70, width: 10, text: 'g' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'NET_QUANTITY',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not join across rows or onto a bigger number', () => {
|
||||
const otherRow = [
|
||||
...without('net'),
|
||||
|
||||
@@ -203,7 +203,8 @@ function distanceTo(span: Span, candidate: Candidate): number {
|
||||
function groupByDistance(list: Candidate[]): Candidate[][] {
|
||||
const groups: Candidate[][] = [];
|
||||
let span: Span = { top: 0, bottom: 0 };
|
||||
for (const candidate of list) {
|
||||
// An empty box (a placeholder without a value) must not bridge distances.
|
||||
for (const candidate of list.filter((entry) => entry.normalized !== '')) {
|
||||
const group = groups[groups.length - 1];
|
||||
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
||||
group.push(candidate);
|
||||
@@ -295,9 +296,20 @@ function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
|
||||
const pairs: Candidate[][] = [];
|
||||
views.visible.forEach((first, index) => {
|
||||
const second = views.visible[index + 1];
|
||||
// Side by side: the boxes overlap vertically (a big number with a small,
|
||||
// bottom-aligned unit), the unit lies to the right and not far away.
|
||||
if (
|
||||
!second ||
|
||||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
|
||||
distanceTo(
|
||||
{
|
||||
top: first.element.y,
|
||||
bottom: first.element.y + first.element.height,
|
||||
},
|
||||
second,
|
||||
) > 0 ||
|
||||
second.element.x < first.element.x ||
|
||||
second.element.x - (first.element.x + first.element.width) >
|
||||
MAX_JOIN_GAP_MM
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -528,6 +540,7 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
const candidates = candidatesOf(input, measurer);
|
||||
const visible = candidates.filter((candidate) => !candidate.clipped);
|
||||
const views = textViews(visible);
|
||||
const allViews = textViews(candidates);
|
||||
const combined = views.full;
|
||||
|
||||
const gaps: LabelFinding[] = [];
|
||||
@@ -558,13 +571,20 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
||||
];
|
||||
holders.forEach((candidate) => carriers.add(candidate));
|
||||
// Clipped parts of a split phrase: those that would be part of the match
|
||||
// had they been visible.
|
||||
const clippedSplit = new Set(
|
||||
splitPhrases.flatMap((phrase) =>
|
||||
groupsHolding(allViews, phrase)
|
||||
.flat()
|
||||
.filter((candidate) => candidate.clipped),
|
||||
),
|
||||
);
|
||||
const clippedHolders = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.clipped &&
|
||||
(particular.isCarriedBy(candidate.normalized) ||
|
||||
splitPhrases.some((phrase) =>
|
||||
sharesWord(candidate.normalized, phrase),
|
||||
)),
|
||||
clippedSplit.has(candidate)),
|
||||
);
|
||||
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
||||
if (!isOnLabel) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { drawProofWatermark } from './label-proof-watermark';
|
||||
|
||||
type Call = [name: string, ...args: unknown[]];
|
||||
|
||||
/** A PDFKit stand-in that records the drawing calls in order. */
|
||||
function fakeDoc() {
|
||||
const calls: Call[] = [];
|
||||
const doc: Record<string, unknown> = {};
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) => {
|
||||
calls.push([name, ...args]);
|
||||
return doc;
|
||||
};
|
||||
for (const name of [
|
||||
'save',
|
||||
'restore',
|
||||
'rotate',
|
||||
'fillColor',
|
||||
'fillOpacity',
|
||||
'font',
|
||||
'fontSize',
|
||||
'text',
|
||||
]) {
|
||||
doc[name] = record(name);
|
||||
}
|
||||
doc.widthOfString = () => 100;
|
||||
return { doc: doc as unknown as PDFKit.PDFDocument, calls };
|
||||
}
|
||||
|
||||
const callsOf = (calls: Call[], name: string) =>
|
||||
calls.filter(([callName]) => callName === name);
|
||||
|
||||
describe('drawProofWatermark', () => {
|
||||
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 10, 20, 255, 99);
|
||||
|
||||
const texts = callsOf(calls, 'text');
|
||||
expect(texts).toHaveLength(1);
|
||||
expect(texts[0][1]).toBe('MUSTER – nicht verkaufsfähig');
|
||||
expect(callsOf(calls, 'fillOpacity')).toEqual([['fillOpacity', 0.5]]);
|
||||
const [, color] = callsOf(calls, 'fillColor')[0];
|
||||
expect(color).toBe('#808080');
|
||||
const [, angle] = callsOf(calls, 'rotate')[0];
|
||||
expect(angle).toBeLessThan(0);
|
||||
expect(angle).toBeGreaterThan(-90);
|
||||
});
|
||||
|
||||
it('saves and restores the graphics state around the watermark', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 0, 0, 255, 99);
|
||||
|
||||
expect(calls[0][0]).toBe('save');
|
||||
expect(calls[calls.length - 1][0]).toBe('restore');
|
||||
});
|
||||
|
||||
it('keeps the text on the label: centred on the label box', () => {
|
||||
const { doc, calls } = fakeDoc();
|
||||
|
||||
drawProofWatermark(doc, 10, 20, 200, 100);
|
||||
|
||||
const [, , options] = callsOf(calls, 'rotate')[0] as [
|
||||
string,
|
||||
number,
|
||||
{ origin: [number, number] },
|
||||
];
|
||||
expect(options.origin).toEqual([110, 70]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export const PROOF_WATERMARK_TEXT = 'MUSTER – nicht verkaufsfähig';
|
||||
|
||||
const WATERMARK_COLOR = '#808080';
|
||||
const WATERMARK_OPACITY = 0.5;
|
||||
const WATERMARK_FONT = 'Helvetica-Bold';
|
||||
// The text runs along the label's diagonal; this share of it is covered.
|
||||
const DIAGONAL_COVERAGE = 0.8;
|
||||
// Never taller than this share of the label's height.
|
||||
const MAX_HEIGHT_SHARE = 0.5;
|
||||
const MEASURE_SIZE_PT = 10;
|
||||
|
||||
/**
|
||||
* Draws the proof stamp "MUSTER – nicht verkaufsfähig" over one label: grey,
|
||||
* half opaque, rotated along the label's diagonal and centred. Called once
|
||||
* per label, after its content. The graphics state is restored, so nothing
|
||||
* leaks into the next label.
|
||||
*/
|
||||
export function drawProofWatermark(
|
||||
doc: PDFKit.PDFDocument,
|
||||
originX: number,
|
||||
originY: number,
|
||||
widthPt: number,
|
||||
heightPt: number,
|
||||
): void {
|
||||
const centerX = originX + widthPt / 2;
|
||||
const centerY = originY + heightPt / 2;
|
||||
const angleDeg = (Math.atan2(heightPt, widthPt) * 180) / Math.PI;
|
||||
|
||||
doc.save();
|
||||
doc.font(WATERMARK_FONT).fontSize(MEASURE_SIZE_PT);
|
||||
const widthAtMeasureSize = doc.widthOfString(PROOF_WATERMARK_TEXT);
|
||||
const targetWidth = Math.hypot(widthPt, heightPt) * DIAGONAL_COVERAGE;
|
||||
const fontSize = Math.min(
|
||||
(MEASURE_SIZE_PT * targetWidth) / widthAtMeasureSize,
|
||||
heightPt * MAX_HEIGHT_SHARE,
|
||||
);
|
||||
const textWidth = (widthAtMeasureSize * fontSize) / MEASURE_SIZE_PT;
|
||||
|
||||
doc.fillColor(WATERMARK_COLOR).fillOpacity(WATERMARK_OPACITY);
|
||||
doc.rotate(-angleDeg, { origin: [centerX, centerY] });
|
||||
doc.fontSize(fontSize);
|
||||
doc.text(
|
||||
PROOF_WATERMARK_TEXT,
|
||||
centerX - textWidth / 2,
|
||||
centerY - fontSize / 2,
|
||||
{ lineBreak: false },
|
||||
);
|
||||
doc.restore();
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -12,18 +16,34 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
import { LabelTemplatesService } from './label-templates.service';
|
||||
|
||||
// Far above any sheet grid; the service clamps to the real slot count.
|
||||
const MAX_START_POSITION = 1000;
|
||||
|
||||
@ApiTags('label-templates')
|
||||
@ApiBearerAuth()
|
||||
@Controller('label-templates')
|
||||
export class LabelTemplatesController {
|
||||
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
|
||||
constructor(
|
||||
private readonly labelTemplatesService: LabelTemplatesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
// A check or print reads the bottling (lot, dates, jar size), so it needs
|
||||
// the right to see bottlings as well as the label permission.
|
||||
private async assertCanReadBottling(userId: string): Promise<void> {
|
||||
if (!(await this.permissionResolver.hasAny(userId, ['bottling:read']))) {
|
||||
throw new ForbiddenException('Missing permission: bottling:read');
|
||||
}
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get()
|
||||
@@ -31,6 +51,30 @@ export class LabelTemplatesController {
|
||||
return this.labelTemplatesService.findAll();
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Post('check')
|
||||
@HttpCode(200)
|
||||
async checkDraft(
|
||||
@Body() dto: CheckLabelTemplateDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (dto.bottlingBatchId) {
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
}
|
||||
return this.labelTemplatesService.checkDraft(dto);
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get(':id/check/:bottlingBatchId')
|
||||
async checkForBottling(
|
||||
@Param('id') id: string,
|
||||
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
return this.labelTemplatesService.checkForBottling(id, bottlingBatchId);
|
||||
}
|
||||
|
||||
@RequirePermission('label-templates:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
@@ -69,12 +113,25 @@ export class LabelTemplatesController {
|
||||
@Param('bottlingBatchId') bottlingBatchId: string,
|
||||
@Query('startPosition', new ParseIntPipe({ optional: true }))
|
||||
startPosition: number | undefined,
|
||||
@Query('proof', new ParseBoolPipe({ optional: true }))
|
||||
proof: boolean | undefined,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (
|
||||
startPosition !== undefined &&
|
||||
(startPosition < 1 || startPosition > MAX_START_POSITION)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`startPosition must be between 1 and ${MAX_START_POSITION}`,
|
||||
);
|
||||
}
|
||||
await this.assertCanReadBottling(actor.id);
|
||||
const pdf = await this.labelTemplatesService.renderLabelsPdf(
|
||||
id,
|
||||
bottlingBatchId,
|
||||
startPosition,
|
||||
{ startPosition, proof },
|
||||
actor.id,
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
LabelTemplatesService,
|
||||
buildPlaceholderValues,
|
||||
@@ -7,12 +7,16 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LabelElement } from './label-element.types';
|
||||
import { LabelElement, LabelTextElement } from './label-element.types';
|
||||
import * as watermark from './label-proof-watermark';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const PROOF = { proof: true };
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster' as string | null,
|
||||
@@ -38,6 +42,7 @@ describe('LabelTemplatesService', () => {
|
||||
update: jest.Mock;
|
||||
};
|
||||
bottlingBatch: { findUnique: jest.Mock };
|
||||
complianceSettings: { findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
@@ -90,6 +95,7 @@ describe('LabelTemplatesService', () => {
|
||||
gapYMm: 2,
|
||||
isDefault: false,
|
||||
active: true,
|
||||
minXHeightMm: 1.2,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -117,6 +123,14 @@ describe('LabelTemplatesService', () => {
|
||||
update: jest.fn(),
|
||||
},
|
||||
bottlingBatch: { findUnique: jest.fn() },
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
netQuantityDigitRules: [
|
||||
{ maxGrams: 1000, minMm: 4 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
@@ -248,7 +262,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf).toBeInstanceOf(Buffer);
|
||||
expect(pdf.length).toBeGreaterThan(0);
|
||||
@@ -261,7 +280,7 @@ describe('LabelTemplatesService', () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'missing'),
|
||||
service.renderLabelsPdf('template-1', 'missing', PROOF, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
@@ -288,7 +307,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
// A PDF with an embedded raster image contains an XObject /Image entry.
|
||||
@@ -323,7 +347,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
|
||||
// regardless of whether anything is actually embedded, so a plain
|
||||
@@ -358,7 +387,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
|
||||
@@ -386,7 +420,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(countPages(pdf)).toBe(3);
|
||||
});
|
||||
@@ -411,7 +450,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
|
||||
// copies still need 2 pages - same as the explicit columns:2 test.
|
||||
@@ -434,7 +478,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// Only fits on a single page if the rotated 15-per-page layout was
|
||||
// actually used - upright would need a second page for the 15th copy.
|
||||
@@ -458,7 +507,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
PROOF,
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// An explicit grid describes a real pre-printed sheet - it must be
|
||||
// honoured as-is (14 per page), needing 2 pages for 15 copies, even
|
||||
@@ -483,7 +537,12 @@ describe('LabelTemplatesService', () => {
|
||||
product: { name: 'Glas 500g' },
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2);
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ startPosition: 2, proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
|
||||
// labels 2-3 fill both slots of page 2.
|
||||
@@ -491,6 +550,296 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('print guard, proof and audit', () => {
|
||||
function textElement(
|
||||
id: string,
|
||||
y: number,
|
||||
placeholderKey: LabelTextElement['placeholderKey'],
|
||||
fontSize = 9,
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: 80,
|
||||
height: 8,
|
||||
text: '',
|
||||
placeholderKey,
|
||||
fontSize,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
};
|
||||
}
|
||||
|
||||
// Every mandatory particular, type big enough: no gap and no warning.
|
||||
const completeElements: LabelTextElement[] = [
|
||||
textElement('legal', 1, 'legalName'),
|
||||
textElement('net', 10, 'jarSize', 16),
|
||||
textElement('mhd', 20, 'bestBeforeLine'),
|
||||
textElement('lot', 29, 'lotLine'),
|
||||
textElement('origin', 38, 'originCountry'),
|
||||
textElement('firm', 47, 'foodBusinessName'),
|
||||
textElement('address', 56, 'foodBusinessAddress'),
|
||||
];
|
||||
const completeTemplate = {
|
||||
...template,
|
||||
id: 'template-1',
|
||||
heightMm: 70,
|
||||
elements: completeElements,
|
||||
};
|
||||
const bottling = {
|
||||
id: 'batch-1',
|
||||
jarSize: 500,
|
||||
jarCount: 3,
|
||||
lotNumber: 'L-001',
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||
product: { name: 'Glas 500g', legalName: 'Blütenhonig' },
|
||||
};
|
||||
const NO_FIRM = { ...SETTINGS, foodBusinessName: null };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue(completeTemplate);
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(bottling);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error('expected a rejection');
|
||||
}
|
||||
|
||||
it('prints a complete label and audits it', async () => {
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('print refuses with 409 and the gap list', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
|
||||
const error = (await rejectionOf(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
)) as ConflictException;
|
||||
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getStatus()).toBe(409);
|
||||
const body = error.getResponse() as {
|
||||
code: string;
|
||||
message: string;
|
||||
gaps: { code: string }[];
|
||||
};
|
||||
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
|
||||
expect(body.message).toEqual(expect.any(String));
|
||||
expect(body.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
// One watermark per label, one label per jar.
|
||||
expect(mark).toHaveBeenCalledTimes(bottling.jarCount);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'PRINT', userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('a real print draws no watermark', async () => {
|
||||
const mark = jest.spyOn(watermark, 'drawProofWatermark');
|
||||
|
||||
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||
|
||||
expect(mark).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
|
||||
await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{ proof: true },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: 'batch-1',
|
||||
action: 'PRINT',
|
||||
userId: 'user-1',
|
||||
afterData: {
|
||||
templateId: 'template-1',
|
||||
copies: 3,
|
||||
proof: true,
|
||||
gapCodes: ['FOOD_BUSINESS'],
|
||||
warningCodes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('a print with warnings only is rendered and audited with the warning codes', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue({
|
||||
...bottling,
|
||||
lotNumber: 'AB-001',
|
||||
});
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||
...completeTemplate,
|
||||
elements: completeElements.map((element) =>
|
||||
element.id === 'lot'
|
||||
? { ...element, placeholderKey: 'lotNumber' }
|
||||
: element,
|
||||
),
|
||||
});
|
||||
|
||||
const pdf = await service.renderLabelsPdf(
|
||||
'template-1',
|
||||
'batch-1',
|
||||
{},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
afterData: expect.objectContaining({
|
||||
proof: false,
|
||||
gapCodes: [],
|
||||
warningCodes: ['LOT_PREFIX'],
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('the audit is written before the PDF is returned and a failing audit fails the print', async () => {
|
||||
const order: string[] = [];
|
||||
auditLog.record.mockImplementation(() => {
|
||||
order.push('audit');
|
||||
return Promise.resolve();
|
||||
});
|
||||
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
|
||||
order.push('returned');
|
||||
expect(order).toEqual(['audit', 'returned']);
|
||||
|
||||
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
).rejects.toThrow('audit down');
|
||||
});
|
||||
|
||||
it('checkDraft without a bottling uses the sample values and sets sample true', async () => {
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
elements: [textElement('legal', 1, 'legalName')],
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
const result = await service.checkDraft(dto);
|
||||
|
||||
expect(result.sample).toBe(true);
|
||||
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
|
||||
const codes = result.gaps.map((gap) => gap.code);
|
||||
// The sample's legal name fills the placeholder; the rest is missing.
|
||||
expect(codes).not.toContain('LEGAL_NAME');
|
||||
expect(codes).toContain('NET_QUANTITY');
|
||||
});
|
||||
|
||||
it('checkDraft with a bottling uses that bottling and sample false', async () => {
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
bottlingBatchId: 'batch-1',
|
||||
elements: completeElements,
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
const result = await service.checkDraft(dto);
|
||||
|
||||
expect(result.sample).toBe(false);
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
|
||||
it('checkDraft answers 404 for an unknown bottling', async () => {
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
|
||||
const dto = {
|
||||
widthMm: 90,
|
||||
heightMm: 70,
|
||||
minXHeightMm: 1.2,
|
||||
bottlingBatchId: 'missing',
|
||||
elements: [],
|
||||
} as unknown as CheckLabelTemplateDto;
|
||||
|
||||
await expect(service.checkDraft(dto)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('checkForBottling uses the placeholder values of that bottling and the master data', async () => {
|
||||
siteSettings.get.mockResolvedValue(NO_FIRM);
|
||||
prisma.complianceSettings.findUnique.mockResolvedValue({
|
||||
netQuantityDigitRules: [{ maxGrams: null, minMm: 1 }],
|
||||
});
|
||||
|
||||
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||
|
||||
expect(result.sample).toBe(false);
|
||||
expect(result.printable).toBe(false);
|
||||
expect(result.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.requirements.netQuantityMinCapHeightMm).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses the print and the check when the compliance settings are missing', async () => {
|
||||
prisma.complianceSettings.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(
|
||||
service.checkForBottling('template-1', 'batch-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checkForBottling ignores non-text elements', async () => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue({
|
||||
...completeTemplate,
|
||||
elements: [
|
||||
...completeElements,
|
||||
{ id: 'qr', type: 'qrCode', x: 0, y: 0, size: 10 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.checkForBottling('template-1', 'batch-1');
|
||||
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlaceholderValues', () => {
|
||||
const bottlingBatch = {
|
||||
jarSize: 500,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -10,16 +14,49 @@ import { LabelTemplate } from '../../generated/prisma';
|
||||
import { computeCropMarks } from './crop-marks';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import { MM_TO_PT, pdfFontName } from './font-metrics';
|
||||
import { lotStartsWithL } from './label-text';
|
||||
import { LabelElement, PlaceholderKey } from './label-element.types';
|
||||
import {
|
||||
MM_TO_PT,
|
||||
pdfFontName,
|
||||
pdfTextMeasurer,
|
||||
TextMeasurer,
|
||||
} from './font-metrics';
|
||||
import { labelDayOf, lotStartsWithL } from './label-text';
|
||||
import {
|
||||
LabelElement,
|
||||
LabelTextElement,
|
||||
PlaceholderKey,
|
||||
} from './label-element.types';
|
||||
import { expectedParticulars, loadDigitRules } from './label-check.loader';
|
||||
import {
|
||||
checkLabel,
|
||||
LabelCheckResult,
|
||||
NetQuantityDigitRule,
|
||||
SAMPLE_BOTTLING,
|
||||
} from './label-particulars';
|
||||
import { drawProofWatermark } from './label-proof-watermark';
|
||||
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const QR_SOURCE_SIZE_PX = 400;
|
||||
|
||||
export interface PrintOptions {
|
||||
/** First free slot on the first sheet (1 = top left). */
|
||||
startPosition?: number;
|
||||
/** A sample print: allowed with gaps, stamped "MUSTER". */
|
||||
proof?: boolean;
|
||||
}
|
||||
|
||||
type PlaceholderBottling = Parameters<typeof buildPlaceholderValues>[0];
|
||||
|
||||
interface CheckContext {
|
||||
settings: Parameters<typeof buildPlaceholderValues>[1];
|
||||
digitRules: NetQuantityDigitRule[];
|
||||
}
|
||||
|
||||
const LABEL_PARTICULARS_MISSING = 'LABEL_PARTICULARS_MISSING';
|
||||
|
||||
interface SheetLayout {
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
@@ -36,15 +73,7 @@ interface SheetLayout {
|
||||
// not to show if the blade lands a fraction beside it.
|
||||
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
||||
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
export { labelDayOf };
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return formatDayDe(labelDayOf(date), true);
|
||||
@@ -140,6 +169,33 @@ export function buildPlaceholderValues(
|
||||
};
|
||||
}
|
||||
|
||||
function textElementsOf(template: LabelTemplate): LabelTextElement[] {
|
||||
return elementsOf(template).filter(
|
||||
(element): element is LabelTextElement => element.type === 'text',
|
||||
);
|
||||
}
|
||||
|
||||
function textElementFromDraft(
|
||||
element: CheckLabelTemplateDto['elements'][number],
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id: element.id,
|
||||
type: 'text',
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
text: element.text ?? '',
|
||||
placeholderKey: element.placeholderKey ?? null,
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily ?? 'sans',
|
||||
bold: element.bold ?? false,
|
||||
italic: element.italic ?? false,
|
||||
color: element.color ?? '#000000',
|
||||
align: element.align ?? 'left',
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LabelTemplatesService {
|
||||
constructor(
|
||||
@@ -244,24 +300,40 @@ export class LabelTemplatesService {
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the labels of one bottling. A mandatory particular that is missing
|
||||
* blocks the print (409) unless it is a proof print. The audit entry is
|
||||
* written before the PDF is handed out: no unaudited print.
|
||||
*/
|
||||
async renderLabelsPdf(
|
||||
templateId: string,
|
||||
bottlingBatchId: string,
|
||||
startPosition = 1,
|
||||
options: PrintOptions,
|
||||
actingUserId: string,
|
||||
): Promise<Buffer> {
|
||||
const proof = options.proof === true;
|
||||
const template = await this.findById(templateId);
|
||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||
where: { id: bottlingBatchId },
|
||||
include: { honeyBatch: true, product: true },
|
||||
});
|
||||
if (!bottlingBatch) {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
const bottlingBatch = await this.findBottling(bottlingBatchId);
|
||||
const context = await this.loadCheckContext();
|
||||
|
||||
const result = this.check(
|
||||
context,
|
||||
textElementsOf(template),
|
||||
template.minXHeightMm,
|
||||
bottlingBatch,
|
||||
false,
|
||||
pdfTextMeasurer(),
|
||||
);
|
||||
if (result.gaps.length > 0 && !proof) {
|
||||
throw new ConflictException({
|
||||
code: LABEL_PARTICULARS_MISSING,
|
||||
message:
|
||||
'Pflichtangaben fehlen auf dem Etikett. Ein Probedruck ist möglich.',
|
||||
gaps: result.gaps,
|
||||
});
|
||||
}
|
||||
|
||||
const values = buildPlaceholderValues(
|
||||
bottlingBatch,
|
||||
await this.siteSettings.get(),
|
||||
);
|
||||
const values = buildPlaceholderValues(bottlingBatch, context.settings);
|
||||
|
||||
const elements = elementsOf(template).map((element) =>
|
||||
element.type === 'text' && element.placeholderKey
|
||||
@@ -283,7 +355,7 @@ export class LabelTemplatesService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.renderPdf(
|
||||
const pdf = await this.renderPdf(
|
||||
elements,
|
||||
{
|
||||
widthMm: template.widthMm,
|
||||
@@ -298,8 +370,94 @@ export class LabelTemplatesService {
|
||||
},
|
||||
bottlingBatch.jarCount,
|
||||
qrCodeBuffer,
|
||||
startPosition,
|
||||
options.startPosition ?? 1,
|
||||
proof,
|
||||
);
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'BottlingBatch',
|
||||
entityId: bottlingBatchId,
|
||||
action: 'PRINT',
|
||||
userId: actingUserId,
|
||||
afterData: {
|
||||
templateId,
|
||||
copies: bottlingBatch.jarCount,
|
||||
proof,
|
||||
gapCodes: result.gaps.map((gap) => gap.code),
|
||||
warningCodes: result.warnings.map((warning) => warning.code),
|
||||
},
|
||||
});
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/** Checks an unsaved draft; without a bottling the sample values fill it. */
|
||||
async checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult> {
|
||||
const bottling = dto.bottlingBatchId
|
||||
? await this.findBottling(dto.bottlingBatchId)
|
||||
: SAMPLE_BOTTLING;
|
||||
return this.check(
|
||||
await this.loadCheckContext(),
|
||||
dto.elements.map(textElementFromDraft),
|
||||
dto.minXHeightMm,
|
||||
bottling,
|
||||
!dto.bottlingBatchId,
|
||||
);
|
||||
}
|
||||
|
||||
/** The same check the print runs, for one saved template and bottling. */
|
||||
async checkForBottling(
|
||||
templateId: string,
|
||||
bottlingBatchId: string,
|
||||
): Promise<LabelCheckResult> {
|
||||
const template = await this.findById(templateId);
|
||||
const bottling = await this.findBottling(bottlingBatchId);
|
||||
return this.check(
|
||||
await this.loadCheckContext(),
|
||||
textElementsOf(template),
|
||||
template.minXHeightMm,
|
||||
bottling,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private async findBottling(id: string) {
|
||||
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
||||
where: { id },
|
||||
include: { honeyBatch: true, product: true },
|
||||
});
|
||||
if (!bottlingBatch) {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
return bottlingBatch;
|
||||
}
|
||||
|
||||
private async loadCheckContext(): Promise<CheckContext> {
|
||||
const [settings, digitRules] = await Promise.all([
|
||||
this.siteSettings.get(),
|
||||
loadDigitRules(this.prisma),
|
||||
]);
|
||||
return { settings, digitRules };
|
||||
}
|
||||
|
||||
// The one place that builds a label check: the print and both check
|
||||
// endpoints go through it.
|
||||
private check(
|
||||
context: CheckContext,
|
||||
elements: LabelTextElement[],
|
||||
minXHeightMm: number,
|
||||
bottling: PlaceholderBottling,
|
||||
sample: boolean,
|
||||
measurer?: TextMeasurer,
|
||||
): LabelCheckResult {
|
||||
return checkLabel({
|
||||
elements,
|
||||
minXHeightMm,
|
||||
digitRules: context.digitRules,
|
||||
values: buildPlaceholderValues(bottling, context.settings),
|
||||
expected: expectedParticulars(bottling, context.settings),
|
||||
sample,
|
||||
measurer,
|
||||
});
|
||||
}
|
||||
|
||||
private renderPdf(
|
||||
@@ -308,6 +466,7 @@ export class LabelTemplatesService {
|
||||
copies: number,
|
||||
qrCodeBuffer: Buffer | null,
|
||||
startPosition: number,
|
||||
proof: boolean,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
@@ -436,10 +595,18 @@ export class LabelTemplatesService {
|
||||
centerX - w / 2,
|
||||
centerY - h / 2,
|
||||
qrCodeBuffer,
|
||||
proof ? { widthPt: w, heightPt: h } : null,
|
||||
);
|
||||
doc.restore();
|
||||
} else {
|
||||
this.drawLabel(doc, elements, originX, originY, qrCodeBuffer);
|
||||
this.drawLabel(
|
||||
doc,
|
||||
elements,
|
||||
originX,
|
||||
originY,
|
||||
qrCodeBuffer,
|
||||
proof ? { widthPt: slotW, heightPt: slotH } : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,10 +620,20 @@ export class LabelTemplatesService {
|
||||
originX: number,
|
||||
originY: number,
|
||||
qrCodeBuffer: Buffer | null,
|
||||
proofStamp: { widthPt: number; heightPt: number } | null,
|
||||
): void {
|
||||
for (const element of elements) {
|
||||
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
|
||||
}
|
||||
if (proofStamp) {
|
||||
drawProofWatermark(
|
||||
doc,
|
||||
originX,
|
||||
originY,
|
||||
proofStamp.widthPt,
|
||||
proofStamp.heightPt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private drawElement(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text as the label check compares it: NFKC, lower case, every character that
|
||||
* is not a letter or digit becomes a space, runs of spaces collapse. So
|
||||
|
||||
@@ -2,12 +2,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
|
||||
export class CreateTreatmentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -27,9 +34,45 @@ export class CreateTreatmentDto {
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Handelsname. Fehlt er, aber ein Mittel oder eine Packung ist gewählt, wird es der Name aus dem Katalog.',
|
||||
})
|
||||
@ValidateIf((o: CreateTreatmentDto) => !o.medicineId && !o.medicineLotId)
|
||||
@IsString()
|
||||
product!: string;
|
||||
@MaxLength(200)
|
||||
product?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'Katalogeintrag (Tierarzneimittel)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
medicineId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description:
|
||||
'Packung, aus der die Dosis gebucht wird. Lieferant, Beleg, Ch.-B. und Tierarzt kommen dann aus der Packung.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
medicineLotId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description:
|
||||
'Dosis je Volk in der Einheit des Mittels (> 0). Mit Packung wird sie gebucht, ohne Packung nur gespeichert.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@IsPositive()
|
||||
doseQuantity?: number | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
import { CreateTreatmentDto } from './create-treatment.dto';
|
||||
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
* repair the colony's history without deleting the record. `apiaryId` corrects
|
||||
* the apiary kept for the Bestandsbuch (where the colony stood on the day);
|
||||
* `medicineId`, `medicineLotId` and `doseQuantity` can be cleared with null.
|
||||
*/
|
||||
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
}) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stand am Behandlungstag (Bestandsbuch) korrigieren',
|
||||
})
|
||||
@IsOptionalNotNull()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z0-9_-]+$/)
|
||||
@MaxLength(64)
|
||||
apiaryId?: string;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, Treatment } from '../../generated/prisma';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
BookedPerLot,
|
||||
treatmentBookingDelta,
|
||||
} from '../veterinary-medicines/medicine-ledger';
|
||||
|
||||
/**
|
||||
* What a treatment takes from the medicine catalogue and the pack book
|
||||
* (Spec 5.2). Both helpers run inside the caller's transaction, for create,
|
||||
* createBulk and update alike, so the three paths cannot drift apart.
|
||||
*/
|
||||
|
||||
/** The smallest dose on the thousandths grid; below it nothing could be booked. */
|
||||
const MIN_DOSE = 0.001;
|
||||
|
||||
export const LOT_SOURCE_MESSAGE = 'Diese Angabe kommt aus der Packung';
|
||||
|
||||
export interface MedicineInput {
|
||||
/** The trade name as entered; undefined when the form sent none. */
|
||||
product?: string | null;
|
||||
/** Update only: the stored name, kept when no medicine is chosen. */
|
||||
currentProduct?: string;
|
||||
/** undefined: not given; null: explicitly empty. */
|
||||
medicineId?: string | null;
|
||||
medicineLotId?: string | null;
|
||||
doseQuantity?: number | null;
|
||||
supplier?: string | null;
|
||||
purchaseProof?: string | null;
|
||||
batchNumber?: string | null;
|
||||
prescribingVet?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedMedicine {
|
||||
medicineId: string | null;
|
||||
medicineLotId: string | null;
|
||||
/** The catalogue name with a medicine, else the entered trade name. */
|
||||
product: string;
|
||||
/** On the thousandths grid, or null. */
|
||||
doseQuantity: number | null;
|
||||
}
|
||||
|
||||
function hasText(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== '';
|
||||
}
|
||||
|
||||
function sameName(a: string, b: string): boolean {
|
||||
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function checkedDose(dose: number | null | undefined): number | null {
|
||||
if (dose === null || dose === undefined) {
|
||||
return null;
|
||||
}
|
||||
const rounded = roundToThousandths(dose);
|
||||
if (!(rounded >= MIN_DOSE)) {
|
||||
throw new BadRequestException('Die Dosis ist zu klein');
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function assertNoLotTextWithLot(input: MedicineInput): void {
|
||||
const given = [
|
||||
input.supplier,
|
||||
input.purchaseProof,
|
||||
input.batchNumber,
|
||||
input.prescribingVet,
|
||||
].some(hasText);
|
||||
if (given) {
|
||||
throw new BadRequestException(LOT_SOURCE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the (merged) state of a treatment against catalogue and pack and
|
||||
* returns what to store. Never fills activeIngredient, withdrawal period or
|
||||
* method from the catalogue - only the trade name follows it.
|
||||
*
|
||||
* `previousMedicineId` (update): the medicine the treatment already had; an
|
||||
* inactive entry only blocks a medicine that is new to the treatment.
|
||||
*/
|
||||
export async function resolveMedicineIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: MedicineInput,
|
||||
mode: 'create' | 'update',
|
||||
previousMedicineId: string | null = null,
|
||||
): Promise<ResolvedMedicine> {
|
||||
const doseQuantity = checkedDose(input.doseQuantity);
|
||||
const lotId = input.medicineLotId ?? null;
|
||||
if (lotId !== null && input.medicineId === null) {
|
||||
throw new BadRequestException(
|
||||
'Bei gewählter Packung muss das Mittel gesetzt bleiben',
|
||||
);
|
||||
}
|
||||
if (lotId !== null) {
|
||||
assertNoLotTextWithLot(input);
|
||||
}
|
||||
|
||||
const lot =
|
||||
lotId === null
|
||||
? null
|
||||
: await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id: lotId },
|
||||
select: { id: true, medicineId: true },
|
||||
});
|
||||
if (lotId !== null && !lot) {
|
||||
throw new NotFoundException('Packung nicht gefunden');
|
||||
}
|
||||
if (lot && input.medicineId && input.medicineId !== lot.medicineId) {
|
||||
throw new BadRequestException('Die Packung gehört zu einem anderen Mittel');
|
||||
}
|
||||
|
||||
const medicineId = lot?.medicineId ?? input.medicineId ?? null;
|
||||
const entered = hasText(input.product) ? input.product!.trim() : undefined;
|
||||
if (medicineId === null) {
|
||||
const product = entered ?? input.currentProduct;
|
||||
if (product === undefined) {
|
||||
throw new BadRequestException('Bitte Mittel oder Handelsname angeben');
|
||||
}
|
||||
return { medicineId: null, medicineLotId: null, product, doseQuantity };
|
||||
}
|
||||
|
||||
// Shared: the entry cannot be deactivated before this transaction ends.
|
||||
await lockRowsForShare(tx, 'VeterinaryMedicine', [medicineId]);
|
||||
const medicine = await tx.veterinaryMedicine.findUnique({
|
||||
where: { id: medicineId },
|
||||
select: { id: true, name: true, active: true },
|
||||
});
|
||||
if (!medicine) {
|
||||
throw new NotFoundException('Mittel nicht gefunden');
|
||||
}
|
||||
if (
|
||||
!medicine.active &&
|
||||
(mode === 'create' || medicineId !== previousMedicineId)
|
||||
) {
|
||||
throw new BadRequestException('Das Mittel ist deaktiviert.');
|
||||
}
|
||||
if (entered !== undefined && !sameName(entered, medicine.name)) {
|
||||
throw new BadRequestException(
|
||||
'Mittel und Katalogeintrag passen nicht zusammen',
|
||||
);
|
||||
}
|
||||
return {
|
||||
medicineId,
|
||||
medicineLotId: lotId,
|
||||
product: medicine.name,
|
||||
doseQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sums the TREATMENT movements one treatment has so far, per lot. */
|
||||
export async function bookingsOf(
|
||||
tx: Prisma.TransactionClient,
|
||||
treatmentId: string,
|
||||
): Promise<BookedPerLot[]> {
|
||||
const sums = await tx.veterinaryMedicineMovement.groupBy({
|
||||
by: ['lotId'],
|
||||
where: { treatmentId, type: 'TREATMENT' },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
return sums.map((sum) => ({
|
||||
lotId: sum.lotId,
|
||||
quantity: sum._sum.quantity ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the movements of the treatment add up to its state: -dose on its lot,
|
||||
* nothing on any other. Existing movements are never touched, a changed lot or
|
||||
* dose is a counter-booking plus a new booking (treatmentBookingDelta). The
|
||||
* lots are locked in ascending id order, after the treatment row, so two
|
||||
* edits cannot wait for each other and an inventory count (which reads the
|
||||
* rest under the same lot lock) sees either all of this booking or none.
|
||||
*
|
||||
* `booked`: the movements so far; omitted, they are read (a new treatment has
|
||||
* none and passes []).
|
||||
*/
|
||||
export async function bookTreatmentIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
treatment: Treatment,
|
||||
userId: string,
|
||||
booked?: readonly BookedPerLot[],
|
||||
): Promise<void> {
|
||||
const current = booked ?? (await bookingsOf(tx, treatment.id));
|
||||
const { medicineLotId, doseQuantity } = treatment;
|
||||
const desired =
|
||||
typeof medicineLotId === 'string' && typeof doseQuantity === 'number'
|
||||
? { lotId: medicineLotId, dose: doseQuantity }
|
||||
: null;
|
||||
const deltas = treatmentBookingDelta(current, desired);
|
||||
if (deltas.length === 0) {
|
||||
return;
|
||||
}
|
||||
const lotIds = deltas.map((delta) => delta.lotId);
|
||||
await lockRowsForUpdate(tx, 'VeterinaryMedicineLot', lotIds);
|
||||
if (desired) {
|
||||
// The lot was read before it was locked: it may have changed medicine.
|
||||
const lots = await tx.veterinaryMedicineLot.findMany({
|
||||
where: { id: { in: lotIds } },
|
||||
select: { id: true, medicineId: true },
|
||||
});
|
||||
const wanted = lots.find((lot) => lot.id === desired.lotId);
|
||||
if (!wanted || wanted.medicineId !== treatment.medicineId) {
|
||||
throw new ConflictException(
|
||||
'Die Packung wurde gerade geändert. Bitte neu laden.',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const delta of [...deltas].sort((a, b) =>
|
||||
a.lotId < b.lotId ? -1 : a.lotId > b.lotId ? 1 : 0,
|
||||
)) {
|
||||
await tx.veterinaryMedicineMovement.create({
|
||||
data: {
|
||||
type: 'TREATMENT',
|
||||
quantity: delta.quantity,
|
||||
treatmentId: treatment.id,
|
||||
lotId: delta.lotId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateTreatmentDto: {
|
||||
dto: UpdateTreatmentDto,
|
||||
notNull: ['colonyId', 'date', 'product', 'apiaryId'],
|
||||
clearable: [
|
||||
'startDate',
|
||||
'endDate',
|
||||
'activeIngredient',
|
||||
'amount',
|
||||
'method',
|
||||
'reason',
|
||||
'batchNumber',
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'prescribingVet',
|
||||
'notes',
|
||||
'withdrawalPeriodDays',
|
||||
'medicineId',
|
||||
'medicineLotId',
|
||||
'doseQuantity',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Treatment update DTO', contracts);
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -26,6 +30,10 @@ describe('TreatmentsService', () => {
|
||||
update: jest.Mock;
|
||||
};
|
||||
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
apiary: { findUnique: jest.Mock };
|
||||
veterinaryMedicine: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { create: jest.Mock; groupBy: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
@@ -34,6 +42,7 @@ describe('TreatmentsService', () => {
|
||||
const treatment = {
|
||||
id: 'treatment-1',
|
||||
colonyId: 'colony-1',
|
||||
product: 'Ameisensäure',
|
||||
date: new Date(),
|
||||
};
|
||||
|
||||
@@ -48,6 +57,16 @@ describe('TreatmentsService', () => {
|
||||
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'apiary-1' }) },
|
||||
veterinaryMedicine: { findUnique: jest.fn() },
|
||||
veterinaryMedicineLot: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
veterinaryMedicineMovement: {
|
||||
create: jest.fn(),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
prisma = {
|
||||
@@ -615,4 +634,487 @@ describe('TreatmentsService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('medicine ledger', () => {
|
||||
const oxuvar = {
|
||||
id: 'med-1',
|
||||
name: 'Oxuvar',
|
||||
active: true,
|
||||
unit: 'ML',
|
||||
};
|
||||
const lot = { id: 'lot-1', medicineId: 'med-1' };
|
||||
const base = { colonyId: 'colony-1', date: '2026-08-01' };
|
||||
type CreateData = Record<string, unknown>;
|
||||
type Movement = {
|
||||
data: {
|
||||
type: string;
|
||||
quantity: number;
|
||||
treatmentId: string;
|
||||
lotId: string;
|
||||
userId: string;
|
||||
};
|
||||
};
|
||||
|
||||
const createdData = (): CreateData =>
|
||||
(tx.treatment.create.mock.calls as [{ data: CreateData }][])[0][0].data;
|
||||
const movements = () =>
|
||||
(tx.veterinaryMedicineMovement.create.mock.calls as [Movement][]).map(
|
||||
([arg]) => arg.data,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue(oxuvar);
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(lot);
|
||||
tx.veterinaryMedicineLot.findMany.mockResolvedValue([lot]);
|
||||
tx.treatment.create.mockImplementation(({ data }: { data: CreateData }) =>
|
||||
Promise.resolve({ ...data, id: `t-${String(data.colonyId)}` }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create books the dose from the lot in the same transaction', async () => {
|
||||
await service.create(
|
||||
{ ...base, medicineLotId: 'lot-1', doseQuantity: 30 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(movements()).toEqual([
|
||||
{
|
||||
type: 'TREATMENT',
|
||||
quantity: -30,
|
||||
treatmentId: 't-colony-1',
|
||||
lotId: 'lot-1',
|
||||
userId: 'user-1',
|
||||
},
|
||||
]);
|
||||
// After the treatment row, before the audit entry.
|
||||
const createOrder = tx.treatment.create.mock.invocationCallOrder[0];
|
||||
const bookOrder =
|
||||
tx.veterinaryMedicineMovement.create.mock.invocationCallOrder[0];
|
||||
const auditOrder = auditLog.record.mock.invocationCallOrder[0];
|
||||
expect(createOrder).toBeLessThan(bookOrder);
|
||||
expect(bookOrder).toBeLessThan(auditOrder);
|
||||
});
|
||||
|
||||
it('rejects a lot of another medicine', async () => {
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||
id: 'lot-1',
|
||||
medicineId: 'med-2',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...base, medicineId: 'med-1', medicineLotId: 'lot-1' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects supplier text together with a lot', async () => {
|
||||
for (const field of [
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'batchNumber',
|
||||
'prescribingVet',
|
||||
]) {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...base, medicineLotId: 'lot-1', [field]: 'Text' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('Diese Angabe kommt aus der Packung');
|
||||
}
|
||||
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes the product name from the catalogue', async () => {
|
||||
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
|
||||
|
||||
expect(createdData()).toMatchObject({
|
||||
product: 'Oxuvar',
|
||||
medicineId: 'med-1',
|
||||
medicineLotId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects medicine and product that differ ignoring case', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...base, medicineId: 'med-1', product: 'Api Bioxal' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow('Mittel und Katalogeintrag passen nicht zusammen');
|
||||
await service.create(
|
||||
{ ...base, medicineId: 'med-1', product: ' oxuvar ' },
|
||||
'user-1',
|
||||
);
|
||||
expect(createdData().product).toBe('Oxuvar');
|
||||
});
|
||||
|
||||
it('rejects an inactive catalogue entry on create but allows editing an existing treatment of it', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
...oxuvar,
|
||||
active: false,
|
||||
});
|
||||
await expect(
|
||||
service.create({ ...base, medicineId: 'med-1' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
const existing = {
|
||||
...treatment,
|
||||
product: 'Oxuvar',
|
||||
medicineId: 'med-1',
|
||||
medicineLotId: null,
|
||||
doseQuantity: null,
|
||||
};
|
||||
tx.treatment.findUnique.mockResolvedValue(existing);
|
||||
tx.treatment.update.mockResolvedValue(existing);
|
||||
await expect(
|
||||
service.update('treatment-1', { notes: 'neu' }, 'user-1'),
|
||||
).resolves.toBe(existing);
|
||||
});
|
||||
|
||||
it('sets medicineId from the lot when only the lot is given', async () => {
|
||||
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
|
||||
|
||||
expect(createdData()).toMatchObject({
|
||||
medicineId: 'med-1',
|
||||
medicineLotId: 'lot-1',
|
||||
product: 'Oxuvar',
|
||||
});
|
||||
});
|
||||
|
||||
it('never fills withdrawal or ingredient from the catalogue', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
...oxuvar,
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
applicationMethod: 'Träufeln',
|
||||
dosePerColony: 5,
|
||||
});
|
||||
|
||||
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
|
||||
|
||||
const data = createdData();
|
||||
expect(data.activeIngredient).toBeUndefined();
|
||||
expect(data.withdrawalPeriodDays).toBeUndefined();
|
||||
expect(data.method).toBeUndefined();
|
||||
expect(data.doseQuantity).toBeNull();
|
||||
});
|
||||
|
||||
it('a dose without a lot is stored and books nothing', async () => {
|
||||
await service.create(
|
||||
{ ...base, product: 'Ameisensäure', doseQuantity: 12.3456 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(createdData().doseQuantity).toBe(12.346);
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a dose below one thousandth', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...base, product: 'X', doseQuantity: 0.0004 },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('a lot without a dose books nothing', async () => {
|
||||
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown lot and an unknown medicine with 404', async () => {
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.create({ ...base, medicineLotId: 'nope' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.create({ ...base, medicineId: 'nope' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a missing product when no medicine is chosen', async () => {
|
||||
await expect(
|
||||
service.create({ ...base }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('bulk books one movement per colony', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-2', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
|
||||
await service.createBulk(
|
||||
{
|
||||
date: '2026-08-01',
|
||||
medicineLotId: 'lot-1',
|
||||
doseQuantity: 5,
|
||||
colonyIds: ['colony-1', 'colony-2'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(movements()).toEqual([
|
||||
expect.objectContaining({ treatmentId: 't-colony-1', quantity: -5 }),
|
||||
expect.objectContaining({ treatmentId: 't-colony-2', quantity: -5 }),
|
||||
]);
|
||||
expect(tx.veterinaryMedicineLot.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bulk rolls back all bookings when one colony fails', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.createBulk(
|
||||
{
|
||||
date: '2026-08-01',
|
||||
medicineLotId: 'lot-1',
|
||||
doseQuantity: 5,
|
||||
colonyIds: ['colony-1', 'colony-2'],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
expect(tx.treatment.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const booked = {
|
||||
...treatment,
|
||||
product: 'Oxuvar',
|
||||
medicineId: 'med-1',
|
||||
medicineLotId: 'lot-1',
|
||||
doseQuantity: 30,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx.treatment.findUnique.mockResolvedValue(booked);
|
||||
tx.treatment.update.mockImplementation(
|
||||
({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({
|
||||
...booked,
|
||||
...Object.fromEntries(
|
||||
Object.entries(data).filter(([, v]) => v !== undefined),
|
||||
),
|
||||
}),
|
||||
);
|
||||
tx.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||
{ lotId: 'lot-1', _sum: { quantity: -30 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('books only the delta when the dose changes and moves the booking when the lot changes', async () => {
|
||||
await service.update('treatment-1', { doseQuantity: 20 }, 'user-1');
|
||||
expect(movements()).toEqual([
|
||||
expect.objectContaining({ lotId: 'lot-1', quantity: 10 }),
|
||||
]);
|
||||
|
||||
tx.veterinaryMedicineMovement.create.mockClear();
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||
id: 'lot-2',
|
||||
medicineId: 'med-1',
|
||||
});
|
||||
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{ id: 'lot-1', medicineId: 'med-1' },
|
||||
{ id: 'lot-2', medicineId: 'med-1' },
|
||||
]);
|
||||
await service.update(
|
||||
'treatment-1',
|
||||
{ medicineLotId: 'lot-2' },
|
||||
'user-1',
|
||||
);
|
||||
expect(movements()).toEqual([
|
||||
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||
expect.objectContaining({ lotId: 'lot-2', quantity: -30 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('books nothing when neither lot nor dose change', async () => {
|
||||
await service.update('treatment-1', { notes: 'neu' }, 'user-1');
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update with dose null or lot null reverses the booking', async () => {
|
||||
await service.update('treatment-1', { doseQuantity: null }, 'user-1');
|
||||
expect(movements()).toEqual([
|
||||
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||
]);
|
||||
|
||||
tx.veterinaryMedicineMovement.create.mockClear();
|
||||
await service.update('treatment-1', { medicineLotId: null }, 'user-1');
|
||||
expect(movements()).toEqual([
|
||||
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
|
||||
]);
|
||||
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'treatment-1' },
|
||||
data: expect.objectContaining({ medicineLotId: null }) as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('update rejects medicineId null while a lot is set', async () => {
|
||||
await expect(
|
||||
service.update('treatment-1', { medicineId: null }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.treatment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update checks the merged state: supplier text for a treatment with a lot is rejected', async () => {
|
||||
await expect(
|
||||
service.update('treatment-1', { supplier: 'Apotheke' }, 'user-1'),
|
||||
).rejects.toThrow('Diese Angabe kommt aus der Packung');
|
||||
});
|
||||
|
||||
it('update reads before after locking Treatment and then the lots in ascending id order', async () => {
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||
id: 'lot-0',
|
||||
medicineId: 'med-1',
|
||||
});
|
||||
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{ id: 'lot-0', medicineId: 'med-1' },
|
||||
{ id: 'lot-1', medicineId: 'med-1' },
|
||||
]);
|
||||
|
||||
await service.update(
|
||||
'treatment-1',
|
||||
{ medicineLotId: 'lot-0' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const locks = (
|
||||
tx.$queryRaw.mock.calls as [
|
||||
{ strings: string[]; values: unknown[] },
|
||||
][]
|
||||
).map(([sql]) => ({
|
||||
table: sql.strings.join('?'),
|
||||
values: sql.values,
|
||||
}));
|
||||
const treatmentLock = locks.findIndex((l) =>
|
||||
l.table.includes('"Treatment"'),
|
||||
);
|
||||
const lotLock = locks.findIndex((l) =>
|
||||
l.table.includes('"VeterinaryMedicineLot"'),
|
||||
);
|
||||
expect(treatmentLock).toBe(0);
|
||||
expect(lotLock).toBeGreaterThan(treatmentLock);
|
||||
expect(locks[lotLock].values).toEqual([['lot-0', 'lot-1']]);
|
||||
const orders = tx.$queryRaw.mock.invocationCallOrder;
|
||||
expect(orders[treatmentLock]).toBeLessThan(
|
||||
tx.treatment.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(
|
||||
tx.treatment.findUnique.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(orders[lotLock]);
|
||||
});
|
||||
|
||||
it('re-filing to another colony leaves the stock untouched', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
|
||||
|
||||
await service.update('treatment-1', { colonyId: 'colony-2' }, 'user-1');
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the colony before the treatment, then the medicine, then the lots', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
|
||||
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{ id: 'lot-1', medicineId: 'med-1' },
|
||||
]);
|
||||
|
||||
await service.update(
|
||||
'treatment-1',
|
||||
{ colonyId: 'colony-2', doseQuantity: 20 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const tables = (
|
||||
tx.$queryRaw.mock.calls as [{ strings: string[] }][]
|
||||
).map(([sql]) => sql.strings.join('?'));
|
||||
const order = [
|
||||
'"Colony"',
|
||||
'"Treatment"',
|
||||
'"VeterinaryMedicine"',
|
||||
'"VeterinaryMedicineLot"',
|
||||
].map((table) => tables.findIndex((sql) => sql.includes(table)));
|
||||
expect(order.every((index) => index >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
});
|
||||
|
||||
it('a notes-only edit succeeds although the lot moved to another medicine', async () => {
|
||||
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
|
||||
id: 'lot-1',
|
||||
medicineId: 'med-9',
|
||||
});
|
||||
|
||||
await service.update('treatment-1', { notes: 'nur Notiz' }, 'user-1');
|
||||
|
||||
expect(tx.veterinaryMedicineLot.findUnique).not.toHaveBeenCalled();
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
const [[updateArg]] = tx.treatment.update.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
expect(updateArg.data).not.toHaveProperty('medicineLotId');
|
||||
expect(updateArg.data).not.toHaveProperty('product');
|
||||
});
|
||||
|
||||
it('update accepts apiaryId and rejects an unknown apiary with 404', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue({ id: 'apiary-9' });
|
||||
await service.update('treatment-1', { apiaryId: 'apiary-9' }, 'user-1');
|
||||
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'treatment-1' },
|
||||
data: expect.objectContaining({ apiaryId: 'apiary-9' }) as unknown,
|
||||
});
|
||||
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.update('treatment-1', { apiaryId: 'nope' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('conflicts when the lot changed its medicine meanwhile', async () => {
|
||||
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{ id: 'lot-1', medicineId: 'med-9' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.update('treatment-1', { doseQuantity: 20 }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('re-sets the catalogue name when the medicine changes without a product', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'med-2',
|
||||
name: 'Api Bioxal',
|
||||
active: true,
|
||||
unit: 'ML',
|
||||
});
|
||||
|
||||
await service.update(
|
||||
'treatment-1',
|
||||
{ medicineId: 'med-2', medicineLotId: null, doseQuantity: null },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.treatment.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'treatment-1' },
|
||||
data: expect.objectContaining({
|
||||
product: 'Api Bioxal',
|
||||
medicineId: 'med-2',
|
||||
}) as unknown,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
} from '../common/bulk/bulk-create';
|
||||
import { runBulk } from '../common/bulk/run-bulk';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
bookTreatmentIn,
|
||||
MedicineInput,
|
||||
resolveMedicineIn,
|
||||
ResolvedMedicine,
|
||||
} from './treatment-medicine';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import {
|
||||
lastBlockedDay,
|
||||
@@ -41,14 +47,19 @@ export interface WithdrawalStatus {
|
||||
// The row a new treatment is stored as - for create and createBulk alike, so
|
||||
// a bulk entry stores exactly what single entries would. Where the colony
|
||||
// stands today is where it was treated: the apiary is kept on the treatment
|
||||
// for the Bestandsbuch, the colony may be moved later.
|
||||
// for the Bestandsbuch, the colony may be moved later. Catalogue, pack and
|
||||
// dose come from resolveMedicineIn; with a pack, supplier, proof, batch
|
||||
// number and vet stay empty (they are read from the pack, never copied).
|
||||
function treatmentRow(
|
||||
dto: CreateTreatmentDto,
|
||||
apiaryId: string,
|
||||
actingUserId: string,
|
||||
medicine: ResolvedMedicine,
|
||||
): Prisma.TreatmentUncheckedCreateInput {
|
||||
return {
|
||||
...dto,
|
||||
...medicineColumns(medicine),
|
||||
...(medicine.medicineLotId === null ? {} : LOT_TEXT_CLEARED),
|
||||
apiaryId,
|
||||
date: new Date(dto.date),
|
||||
startDate: optionalDate(dto.startDate),
|
||||
@@ -57,6 +68,81 @@ function treatmentRow(
|
||||
};
|
||||
}
|
||||
|
||||
// The patch fields that make medicine, lot and dose consistency matter.
|
||||
const MEDICINE_FIELDS = [
|
||||
'product',
|
||||
'medicineId',
|
||||
'medicineLotId',
|
||||
'doseQuantity',
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'batchNumber',
|
||||
'prescribingVet',
|
||||
] as const;
|
||||
|
||||
const LOT_TEXT_CLEARED = {
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
batchNumber: null,
|
||||
prescribingVet: null,
|
||||
} as const;
|
||||
|
||||
function medicineColumns(medicine: ResolvedMedicine) {
|
||||
return {
|
||||
product: medicine.product,
|
||||
medicineId: medicine.medicineId,
|
||||
medicineLotId: medicine.medicineLotId,
|
||||
doseQuantity: medicine.doseQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
function medicineInputOf(dto: CreateTreatmentDto): MedicineInput {
|
||||
return {
|
||||
product: dto.product,
|
||||
medicineId: dto.medicineId,
|
||||
medicineLotId: dto.medicineLotId,
|
||||
doseQuantity: dto.doseQuantity,
|
||||
supplier: dto.supplier,
|
||||
purchaseProof: dto.purchaseProof,
|
||||
batchNumber: dto.batchNumber,
|
||||
prescribingVet: dto.prescribingVet,
|
||||
};
|
||||
}
|
||||
|
||||
// The state an edit leaves behind: what the patch does not mention is what
|
||||
// the treatment had. A missing medicineId stays missing so that a lot alone
|
||||
// can set it; an explicit null stays null so that it is refused with a lot.
|
||||
function mergedMedicineInput(
|
||||
before: Treatment,
|
||||
dto: UpdateTreatmentDto,
|
||||
): MedicineInput {
|
||||
return {
|
||||
product: dto.product,
|
||||
currentProduct: before.product,
|
||||
medicineId:
|
||||
dto.medicineId !== undefined
|
||||
? dto.medicineId
|
||||
: (before.medicineId ?? undefined),
|
||||
medicineLotId:
|
||||
dto.medicineLotId !== undefined
|
||||
? dto.medicineLotId
|
||||
: before.medicineLotId,
|
||||
doseQuantity:
|
||||
dto.doseQuantity !== undefined ? dto.doseQuantity : before.doseQuantity,
|
||||
supplier: dto.supplier !== undefined ? dto.supplier : before.supplier,
|
||||
purchaseProof:
|
||||
dto.purchaseProof !== undefined
|
||||
? dto.purchaseProof
|
||||
: before.purchaseProof,
|
||||
batchNumber:
|
||||
dto.batchNumber !== undefined ? dto.batchNumber : before.batchNumber,
|
||||
prescribingVet:
|
||||
dto.prescribingVet !== undefined
|
||||
? dto.prescribingVet
|
||||
: before.prescribingVet,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TreatmentsService {
|
||||
constructor(
|
||||
@@ -145,9 +231,14 @@ export class TreatmentsService {
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
const medicine = await resolveMedicineIn(
|
||||
tx,
|
||||
medicineInputOf(dto),
|
||||
'create',
|
||||
);
|
||||
return this.recordIn(
|
||||
tx,
|
||||
treatmentRow(dto, colony.apiaryId, actingUserId),
|
||||
treatmentRow(dto, colony.apiaryId, actingUserId, medicine),
|
||||
actingUserId,
|
||||
);
|
||||
});
|
||||
@@ -164,21 +255,39 @@ export class TreatmentsService {
|
||||
actingUserId: string,
|
||||
): Promise<BulkCreateResult<Treatment>> {
|
||||
const { colonyIds, ...shared } = dto;
|
||||
// The shared fields are checked once, in the bulk transaction after the
|
||||
// colonies are locked; every colony then books its own dose. Valid
|
||||
// because runBulk is ONE transaction without retries: the closure lives
|
||||
// for exactly one attempt, so the memoised check can never outlive the
|
||||
// transaction (and the locks) it was made under.
|
||||
let medicine: ResolvedMedicine | undefined;
|
||||
return runBulk(
|
||||
this.prisma,
|
||||
colonyIds.map((colonyId) => ({ colonyId })),
|
||||
(tx, { colonyId }, colony, bulkId) =>
|
||||
this.recordIn(
|
||||
async (tx, { colonyId }, colony, bulkId) => {
|
||||
medicine ??= await resolveMedicineIn(
|
||||
tx,
|
||||
treatmentRow({ ...shared, colonyId }, colony.apiaryId, actingUserId),
|
||||
medicineInputOf({ ...shared, colonyId }),
|
||||
'create',
|
||||
);
|
||||
return this.recordIn(
|
||||
tx,
|
||||
treatmentRow(
|
||||
{ ...shared, colonyId },
|
||||
colony.apiaryId,
|
||||
actingUserId,
|
||||
medicine,
|
||||
),
|
||||
actingUserId,
|
||||
bulkId,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Treatment, audit entry and TreatmentRecorded in the caller's
|
||||
// transaction; calendar entry and follow-up check follow from the event.
|
||||
// Treatment, its booking from the pack, audit entry and TreatmentRecorded
|
||||
// in the caller's transaction; calendar entry and follow-up check follow
|
||||
// from the event.
|
||||
private async recordIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
data: Prisma.TreatmentUncheckedCreateInput,
|
||||
@@ -186,6 +295,7 @@ export class TreatmentsService {
|
||||
bulkId?: string,
|
||||
): Promise<Treatment> {
|
||||
const treatment = await tx.treatment.create({ data });
|
||||
await bookTreatmentIn(tx, treatment, actingUserId, []);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Treatment',
|
||||
@@ -214,6 +324,12 @@ export class TreatmentsService {
|
||||
// TreatmentCorrected moves the calendar event (changed date, colony or
|
||||
// product) but starts no follow-up check.
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Lock order follows LOCKABLE_TABLES: the colony the treatment may be
|
||||
// re-filed to first, then the treatment, then (below) the medicine and
|
||||
// the lots in ascending id order.
|
||||
if (dto.colonyId !== undefined) {
|
||||
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
|
||||
}
|
||||
// The audit "before" is read under the row lock, not before the
|
||||
// transaction: a concurrent edit cannot slip in between.
|
||||
await lockRowForUpdate(tx, 'Treatment', id);
|
||||
@@ -224,22 +340,55 @@ export class TreatmentsService {
|
||||
// Re-filed to another colony: take that colony's apiary as the location.
|
||||
let movedColony: { apiaryId: string | null } | null = null;
|
||||
if (dto.colonyId !== undefined && dto.colonyId !== before.colonyId) {
|
||||
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
|
||||
movedColony = await tx.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
select: { apiaryId: true },
|
||||
});
|
||||
}
|
||||
// An explicit apiary corrects the stand on the day; it wins over the
|
||||
// new colony apiary.
|
||||
if (dto.apiaryId !== undefined) {
|
||||
const apiary = await tx.apiary.findUnique({
|
||||
where: { id: dto.apiaryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Apiary not found');
|
||||
}
|
||||
}
|
||||
// Medicine, lot and dose are checked and booked only when the edit
|
||||
// touches them: a notes-only edit of an old treatment must succeed
|
||||
// even if its lot has since moved to another medicine. The name only
|
||||
// follows the catalogue in that case, too.
|
||||
const touchesMedicine = MEDICINE_FIELDS.some(
|
||||
(field) => dto[field] !== undefined,
|
||||
);
|
||||
const medicine = touchesMedicine
|
||||
? await resolveMedicineIn(
|
||||
tx,
|
||||
mergedMedicineInput(before, dto),
|
||||
'update',
|
||||
before.medicineId,
|
||||
)
|
||||
: null;
|
||||
const treatment = await tx.treatment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
apiaryId: movedColony ? movedColony.apiaryId : undefined,
|
||||
...(medicine ? medicineColumns(medicine) : {}),
|
||||
...(medicine && medicine.medicineLotId !== null
|
||||
? LOT_TEXT_CLEARED
|
||||
: {}),
|
||||
apiaryId:
|
||||
dto.apiaryId ?? (movedColony ? movedColony.apiaryId : undefined),
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
startDate: optionalDate(dto.startDate),
|
||||
endDate: optionalDate(dto.endDate),
|
||||
},
|
||||
});
|
||||
if (medicine) {
|
||||
await bookTreatmentIn(tx, treatment, actingUserId);
|
||||
}
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Treatment',
|
||||
|
||||
Reference in new issue
Block a user