feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
|
||||
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-02T10:00:00.000Z'),
|
||||
snapshot: { version: 1, apiaries: [], totalLiving: 0 },
|
||||
totalColonies: 0,
|
||||
sentOn: null,
|
||||
createdAt: new Date('2027-01-02T10:00:00.000Z'),
|
||||
updatedAt: new Date('2027-01-03T10:00:00.000Z'),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthorityNoticesService.pdf', () => {
|
||||
let service: AuthorityNoticesService;
|
||||
let prisma: {
|
||||
authorityNotice: { findUnique: jest.Mock; findFirst: jest.Mock };
|
||||
siteSettings: { findUnique: jest.Mock };
|
||||
complianceSettings: { findUnique: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
authorityNotice: {
|
||||
findUnique: jest.fn().mockResolvedValue(notice()),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
siteSettings: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
complianceSettings: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'singleton',
|
||||
veterinaryOfficeName: null,
|
||||
veterinaryOfficeAddress: null,
|
||||
registrationNumber: null,
|
||||
tskMemberNumber: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
service = new AuthorityNoticesService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a PDF named after the id only, even with every particular missing', async () => {
|
||||
const result = await service.pdf('n1');
|
||||
|
||||
expect(result.fileName).toBe('anzeige-n1.pdf');
|
||||
expect(result.buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('compares a draft change notice with the newest sent notice', async () => {
|
||||
await service.pdf('n1');
|
||||
|
||||
expect(prisma.authorityNotice.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'SENT',
|
||||
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares a sent change notice with the one sent before it', async () => {
|
||||
const sentOn = new Date('2027-02-01T00:00:00.000Z');
|
||||
prisma.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ status: 'SENT', sentOn }),
|
||||
);
|
||||
|
||||
await service.pdf('n1');
|
||||
|
||||
const call = prisma.authorityNotice.findFirst.mock.calls[0] as [
|
||||
{ where: { id: unknown; OR: unknown[] } },
|
||||
];
|
||||
expect(call[0].where.id).toEqual({ not: 'n1' });
|
||||
expect(call[0].where.OR).toContainEqual({ sentOn: { lt: sentOn } });
|
||||
});
|
||||
|
||||
it('looks for no baseline for a first notice', async () => {
|
||||
prisma.authorityNotice.findUnique.mockResolvedValue(
|
||||
notice({ kind: 'REGISTRATION' }),
|
||||
);
|
||||
|
||||
await service.pdf('n1');
|
||||
|
||||
expect(prisma.authorityNotice.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown id', async () => {
|
||||
prisma.authorityNotice.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.pdf('x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
@@ -36,6 +38,16 @@ export class AuthorityNoticesController {
|
||||
return this.notices.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get(':id/pdf')
|
||||
async pdf(@Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const { buffer, fileName } = await this.notices.pdf(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
|
||||
@@ -18,7 +18,13 @@ 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';
|
||||
import {
|
||||
COMPLIANCE_SETTINGS_ID,
|
||||
complianceSettingsIn,
|
||||
findBaselineIn,
|
||||
} from './herd-status';
|
||||
import { buildNoticePdfModel } from './pdf/authority-notice-model';
|
||||
import { renderAuthorityNoticePdf } from './pdf/authority-notice-pdf';
|
||||
|
||||
const AUDIT_ENTITY = 'AuthorityNotice';
|
||||
const LIST_LIMIT = 200;
|
||||
@@ -108,6 +114,60 @@ export class AuthorityNoticesService {
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDF of a notice. A change notice compares with the notice that was
|
||||
* the baseline when it was (or would be) sent. The file name carries the
|
||||
* id only, never user input.
|
||||
*/
|
||||
async pdf(id: string): Promise<{ buffer: Buffer; fileName: string }> {
|
||||
const notice = await this.findOne(id);
|
||||
const [site, settings, baseline] = await Promise.all([
|
||||
this.prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
|
||||
complianceSettingsIn(this.prisma),
|
||||
notice.kind === 'HERD_CHANGE'
|
||||
? this.baselineFor(notice)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const model = buildNoticePdfModel({
|
||||
notice,
|
||||
baseline,
|
||||
site: {
|
||||
holderName: site?.holderName ?? null,
|
||||
holderAddress: site?.holderAddress ?? null,
|
||||
},
|
||||
settings,
|
||||
now: new Date(),
|
||||
});
|
||||
return {
|
||||
buffer: await renderAuthorityNoticePdf(model),
|
||||
fileName: `anzeige-${notice.id}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A sent notice compares with the one sent before it, a draft with the newest. */
|
||||
private baselineFor(
|
||||
notice: AuthorityNotice,
|
||||
): Promise<AuthorityNotice | null> {
|
||||
if (notice.status !== 'SENT' || !notice.sentOn) {
|
||||
return findBaselineIn(this.prisma);
|
||||
}
|
||||
return this.prisma.authorityNotice.findFirst({
|
||||
where: {
|
||||
status: 'SENT',
|
||||
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
|
||||
id: { not: notice.id },
|
||||
OR: [
|
||||
{ sentOn: { lt: notice.sentOn } },
|
||||
{
|
||||
sentOn: notice.sentOn,
|
||||
createdAt: { lt: notice.createdAt },
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { AuthorityNoticesService } from './authority-notices.service';
|
||||
import { ComplianceEventHandlers } from './compliance.handlers';
|
||||
import { herdStatusIn } from './herd-status';
|
||||
|
||||
jest.mock('./herd-status', () => ({
|
||||
herdStatusIn: jest.fn(),
|
||||
complianceSettingsIn: jest.fn().mockResolvedValue({ id: 'singleton' }),
|
||||
}));
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const herdStatus = herdStatusIn as jest.Mock;
|
||||
|
||||
const envelope = <T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
|
||||
|
||||
const living = { status: 'ACTIVE', healthStatus: null };
|
||||
const ended = { status: 'SOLD', healthStatus: null };
|
||||
const NOW = new Date('2027-03-01T10:00:00Z');
|
||||
|
||||
/** A status with a baseline of 10 colonies on 2 apiaries, now 12 on 3. */
|
||||
const deviating = (triggered = true) => ({
|
||||
baseline: {
|
||||
totalColonies: 10,
|
||||
snapshot: { apiaries: [{ apiaryId: 'a1' }, { apiaryId: 'a2' }] },
|
||||
},
|
||||
current: {
|
||||
totalLiving: 12,
|
||||
apiaries: [{ apiaryId: 'a1' }, { apiaryId: 'a2' }, { apiaryId: 'a3' }],
|
||||
},
|
||||
deviation: { triggered },
|
||||
});
|
||||
|
||||
describe('ComplianceEventHandlers', () => {
|
||||
let notifications: { recordForPermissionOnce: jest.Mock };
|
||||
let notices: { createDraftIn: jest.Mock };
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
authorityNotice: { findMany: jest.Mock };
|
||||
};
|
||||
let handlers: ComplianceEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
herdStatus.mockReset().mockResolvedValue(deviating());
|
||||
notifications = {
|
||||
recordForPermissionOnce: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||
};
|
||||
notices = {
|
||||
createDraftIn: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'notice-1', totalColonies: 14 }),
|
||||
};
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
|
||||
authorityNotice: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
handlers = new ComplianceEventHandlers(
|
||||
notifications as unknown as NotificationsService,
|
||||
notices as unknown as AuthorityNoticesService,
|
||||
);
|
||||
});
|
||||
|
||||
const run = (
|
||||
event: AnyDomainEventEnvelope<
|
||||
'ColonyCreated' | 'ColonyStatusChanged' | 'ColonyEnded'
|
||||
>,
|
||||
) => handlers.herdChange(event, tx as unknown as Prisma.TransactionClient);
|
||||
|
||||
it('registers the two handler ids', () => {
|
||||
const ids = (method: object) =>
|
||||
domainEventHandlerMetadata(method).map((entry) => entry.handlerId);
|
||||
|
||||
// Metadata sits on the prototype function, not on a bound copy.
|
||||
const proto = Object.getPrototypeOf(handlers) as Record<string, object>;
|
||||
expect(ids(proto['herdChange'])).toEqual(['compliance.herd-change']);
|
||||
expect(ids(proto['tskReferenceDay'])).toEqual([
|
||||
'compliance.tsk-reference-day',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('herd-change', () => {
|
||||
it('herd-change notifies each permitted user once while unread', async () => {
|
||||
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
|
||||
|
||||
expect(herdStatus).toHaveBeenCalledWith(tx, NOW);
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
|
||||
'compliance:manage',
|
||||
{
|
||||
priority: 'HINWEIS',
|
||||
category: 'COMPLIANCE',
|
||||
message:
|
||||
'Bestand geändert: jetzt 12 Völker an 3 Ständen. Angezeigt: 10 an 2. Anzeige ans Veterinäramt prüfen.',
|
||||
relatedType: 'ComplianceItem',
|
||||
relatedId: 'herd-change',
|
||||
link: '/compliance',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a status change within living states and one within ended states', async () => {
|
||||
await run(
|
||||
envelope('ColonyStatusChanged', 'c1', { from: living, to: living }),
|
||||
);
|
||||
await run(
|
||||
envelope('ColonyStatusChanged', 'c1', { from: ended, to: ended }),
|
||||
);
|
||||
|
||||
expect(herdStatus).not.toHaveBeenCalled();
|
||||
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a status change between living and ended', async () => {
|
||||
await run(
|
||||
envelope('ColonyStatusChanged', 'c1', { from: living, to: ended }),
|
||||
);
|
||||
await run(
|
||||
envelope('ColonyStatusChanged', 'c1', { from: ended, to: living }),
|
||||
);
|
||||
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles an ended colony', async () => {
|
||||
await run(envelope('ColonyEnded', 'c1', { status: 'SOLD' }));
|
||||
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing without a baseline or without deviation', async () => {
|
||||
herdStatus.mockResolvedValueOnce({ ...deviating(), deviation: null });
|
||||
herdStatus.mockResolvedValueOnce(deviating(false));
|
||||
|
||||
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
|
||||
await run(envelope('ColonyCreated', 'c2', { apiaryId: 'a1' }));
|
||||
|
||||
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
|
||||
expect(notices.createDraftIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a settings toggle from false to true waits for the next herd event', async () => {
|
||||
herdStatus.mockResolvedValueOnce(deviating(false));
|
||||
herdStatus.mockResolvedValueOnce(deviating(true));
|
||||
|
||||
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
|
||||
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
|
||||
|
||||
await run(envelope('ColonyCreated', 'c2', { apiaryId: 'a1' }));
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('the message uses the exact wording with counts and never an address', async () => {
|
||||
herdStatus.mockResolvedValue({
|
||||
...deviating(),
|
||||
current: {
|
||||
totalLiving: 1,
|
||||
apiaries: [{ apiaryId: 'a1', address: 'Hauptstr. 1' }],
|
||||
},
|
||||
});
|
||||
|
||||
await run(envelope('ColonyCreated', 'c1', { apiaryId: 'a1' }));
|
||||
|
||||
const [, input] = notifications.recordForPermissionOnce.mock.calls[0] as [
|
||||
string,
|
||||
{ message: string },
|
||||
];
|
||||
expect(input.message).toBe(
|
||||
'Bestand geändert: jetzt 1 Völker an 1 Ständen. Angezeigt: 10 an 2. Anzeige ans Veterinäramt prüfen.',
|
||||
);
|
||||
expect(input.message).not.toContain('Hauptstr');
|
||||
expect(input.message.length).toBeLessThan(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tsk-reference-day', () => {
|
||||
const tskEvent = envelope('TskReferenceDayReached', 'tsk:2027', {
|
||||
year: 2027,
|
||||
referenceDay: '2027-03-15',
|
||||
});
|
||||
const runTsk = () =>
|
||||
handlers.tskReferenceDay(
|
||||
tskEvent,
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
|
||||
it('tsk handler creates one draft and skips a year already sent', async () => {
|
||||
await runTsk();
|
||||
|
||||
expect(notices.createDraftIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
{
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
referenceDate: new Date('2027-03-15T00:00:00.000Z'),
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(tx.authorityNotice.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
|
||||
notices.createDraftIn.mockClear();
|
||||
tx.authorityNotice.findMany.mockResolvedValue([
|
||||
{ id: 'sent-1', status: 'SENT', totalColonies: 3 },
|
||||
]);
|
||||
await runTsk();
|
||||
|
||||
expect(notices.createDraftIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tsk handler with an existing draft creates none but still reminds', async () => {
|
||||
tx.authorityNotice.findMany.mockResolvedValue([
|
||||
{ id: 'draft-1', status: 'DRAFT', totalColonies: 9 },
|
||||
]);
|
||||
|
||||
await runTsk();
|
||||
|
||||
expect(notices.createDraftIn).not.toHaveBeenCalled();
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
|
||||
'compliance:manage',
|
||||
expect.objectContaining({
|
||||
message:
|
||||
'TSK-Stichtag 15.03.2027: 9 Völker gezählt. Bitte prüfen und melden.',
|
||||
link: '/compliance/notices/draft-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('tsk handler stays silent when the year was sent', async () => {
|
||||
tx.authorityNotice.findMany.mockResolvedValue([
|
||||
{ id: 's', status: 'SENT', totalColonies: 3 },
|
||||
]);
|
||||
|
||||
await runTsk();
|
||||
|
||||
expect(notifications.recordForPermissionOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tsk handler notifies with WARNUNG and the exact wording', async () => {
|
||||
await runTsk();
|
||||
|
||||
expect(notifications.recordForPermissionOnce).toHaveBeenCalledWith(
|
||||
'compliance:manage',
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'COMPLIANCE',
|
||||
message:
|
||||
'TSK-Stichtag 15.03.2027: 14 Völker gezählt. Bitte prüfen und melden.',
|
||||
relatedType: 'ComplianceItem',
|
||||
relatedId: 'tsk-report:2027',
|
||||
link: '/compliance/notices/notice-1',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('a second handler run for the same event adds no second draft', async () => {
|
||||
await runTsk();
|
||||
// The first run left a draft behind.
|
||||
tx.authorityNotice.findMany.mockResolvedValue([
|
||||
{ id: 'notice-1', status: 'DRAFT', totalColonies: 14 },
|
||||
]);
|
||||
await runTsk();
|
||||
|
||||
// The notification itself is deduplicated by recordForPermissionOnce.
|
||||
expect(notices.createDraftIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { dayToDate } from '../common/dates';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
import type { HerdSnapshot } from './herd-snapshot';
|
||||
import {
|
||||
COMPLIANCE_SETTINGS_ID,
|
||||
complianceSettingsIn,
|
||||
herdStatusIn,
|
||||
} from './herd-status';
|
||||
|
||||
const RECIPIENT_PERMISSION = 'compliance:manage';
|
||||
const NOTICE_TOPIC = 'ComplianceItem';
|
||||
const HERD_CHANGE_TOPIC_ID = 'herd-change';
|
||||
|
||||
/** TT.MM.JJJJ for a YYYY-MM-DD day. */
|
||||
function germanDay(day: string): string {
|
||||
const [year, month, date] = day.split('-');
|
||||
return `${date}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authority chain (T6): a change of the herd leaves one notification per
|
||||
* permitted user while the earlier one is unread, and the TSK reference day
|
||||
* prepares the year's count as a draft. Pure DB writers through the delivery
|
||||
* transaction; errors propagate so a delivery is retried. No handler creates
|
||||
* a herd-change notice: the owner reports, the app only reminds.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ComplianceEventHandlers {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly notices: AuthorityNoticesService,
|
||||
) {}
|
||||
|
||||
// A status change only matters when the colony enters or leaves the living
|
||||
// herd; ColonyEnded always does.
|
||||
@OnDomainEvent(
|
||||
[
|
||||
'ColonyCreated',
|
||||
'ColonyMoved',
|
||||
'ColonyEnded',
|
||||
'ColonyStatusChanged',
|
||||
'ApiaryChanged',
|
||||
],
|
||||
'compliance.herd-change',
|
||||
)
|
||||
async herdChange(
|
||||
event: AnyDomainEventEnvelope<
|
||||
| 'ColonyCreated'
|
||||
| 'ColonyMoved'
|
||||
| 'ColonyEnded'
|
||||
| 'ColonyStatusChanged'
|
||||
| 'ApiaryChanged'
|
||||
>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
if (event.type === 'ColonyStatusChanged') {
|
||||
const { from, to } = event.payload;
|
||||
if (
|
||||
isLivingColonyStatus(from.status) === isLivingColonyStatus(to.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The deviation is computed before any notification lock (READ COMMITTED):
|
||||
// at worst a notification comes early or late, never twice.
|
||||
const [{ now }] = await tx.$queryRaw<{ now: Date }[]>`SELECT now() AS now`;
|
||||
const { baseline, current, deviation } = await herdStatusIn(tx, now);
|
||||
if (!baseline || !deviation?.triggered) {
|
||||
return;
|
||||
}
|
||||
const reported = baseline.snapshot as unknown as HerdSnapshot;
|
||||
await this.notifications.recordForPermissionOnce(
|
||||
RECIPIENT_PERMISSION,
|
||||
{
|
||||
priority: 'HINWEIS',
|
||||
category: 'COMPLIANCE',
|
||||
message: `Bestand geändert: jetzt ${current.totalLiving} Völker an ${current.apiaries.length} Ständen. Angezeigt: ${baseline.totalColonies} an ${reported.apiaries.length}. Anzeige ans Veterinäramt prüfen.`,
|
||||
relatedType: NOTICE_TOPIC,
|
||||
relatedId: HERD_CHANGE_TOPIC_ID,
|
||||
link: '/compliance',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
// One draft per year. A year already sent stays silent; with only an open
|
||||
// draft nothing new is created, but the reminder is sent (once while
|
||||
// unread), since the draft may never have been opened. The settings row is
|
||||
// the mutex.
|
||||
@OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day')
|
||||
async tskReferenceDay(
|
||||
event: AnyDomainEventEnvelope<'TskReferenceDayReached'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const { year, referenceDay } = event.payload;
|
||||
await complianceSettingsIn(tx);
|
||||
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||
const existing = await tx.authorityNotice.findMany({
|
||||
where: { kind: 'TSK_REPORT', year, status: { in: ['SENT', 'DRAFT'] } },
|
||||
select: { id: true, status: true, totalColonies: true },
|
||||
});
|
||||
if (existing.some((notice) => notice.status === 'SENT')) {
|
||||
return;
|
||||
}
|
||||
const draft =
|
||||
existing[0] ??
|
||||
(await this.notices.createDraftIn(
|
||||
tx,
|
||||
{ kind: 'TSK_REPORT', year, referenceDate: dayToDate(referenceDay) },
|
||||
null,
|
||||
));
|
||||
await this.notifications.recordForPermissionOnce(
|
||||
RECIPIENT_PERMISSION,
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'COMPLIANCE',
|
||||
message: `TSK-Stichtag ${germanDay(referenceDay)}: ${draft.totalColonies} Völker gezählt. Bitte prüfen und melden.`,
|
||||
relatedType: NOTICE_TOPIC,
|
||||
relatedId: `tsk-report:${year}`,
|
||||
link: `/compliance/notices/${draft.id}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AuthorityNoticesController } from './authority-notices.controller';
|
||||
import { AuthorityNoticesService } from './authority-notices.service';
|
||||
import { ComplianceEventHandlers } from './compliance.handlers';
|
||||
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).
|
||||
// this module too (it carries the event handlers).
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [ComplianceSettingsController, AuthorityNoticesController],
|
||||
providers: [ComplianceSettingsService, AuthorityNoticesService],
|
||||
providers: [
|
||||
ComplianceSettingsService,
|
||||
AuthorityNoticesService,
|
||||
ComplianceEventHandlers,
|
||||
],
|
||||
exports: [ComplianceSettingsService, AuthorityNoticesService],
|
||||
})
|
||||
export class ComplianceModule {}
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { AuthorityNotice } from '../../../generated/prisma';
|
||||
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
|
||||
import { buildNoticePdfModel } from './authority-notice-model';
|
||||
|
||||
function apiary(patch: Partial<HerdApiarySnapshot> = {}): HerdApiarySnapshot {
|
||||
return {
|
||||
apiaryId: 'a1',
|
||||
name: 'Heide',
|
||||
address: 'Heideweg 1, 34117 Kassel',
|
||||
location: 'Kassel',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
livingColonies: 3,
|
||||
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(apiaries: HerdApiarySnapshot[]): HerdSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
takenAt: '2027-01-02T10:00:00.000Z',
|
||||
apiaries,
|
||||
totalLiving: apiaries.reduce((sum, a) => sum + a.livingColonies, 0),
|
||||
};
|
||||
}
|
||||
|
||||
function notice(
|
||||
patch: Partial<AuthorityNotice> = {},
|
||||
apiaries: HerdApiarySnapshot[] = [apiary()],
|
||||
): AuthorityNotice {
|
||||
const snap = snapshot(apiaries);
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'REGISTRATION',
|
||||
status: 'DRAFT',
|
||||
year: null,
|
||||
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
|
||||
countedAt: new Date('2027-01-02T10:00:00.000Z'),
|
||||
snapshot: snap as never,
|
||||
totalColonies: snap.totalLiving,
|
||||
sentOn: null,
|
||||
sentVia: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
openKey: 'REGISTRATION',
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date('2027-01-03T09:00:00.000Z'),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const SITE = {
|
||||
holderName: 'Imkerei Muster',
|
||||
holderAddress: 'Weg 2, 34117 Kassel',
|
||||
};
|
||||
const SETTINGS = {
|
||||
veterinaryOfficeName: 'Veterinäramt Kassel' as string | null,
|
||||
veterinaryOfficeAddress: 'Amtsstr. 1, 34117 Kassel' as string | null,
|
||||
registrationNumber: 'DE-123' as string | null,
|
||||
tskMemberNumber: 'TSK-9' as string | null,
|
||||
};
|
||||
const NOW = new Date('2027-01-05T12:00:00.000Z');
|
||||
|
||||
function build(
|
||||
n: AuthorityNotice,
|
||||
baseline: AuthorityNotice | null = null,
|
||||
overrides: {
|
||||
site?: { holderName: string | null; holderAddress: string | null };
|
||||
settings?: typeof SETTINGS;
|
||||
} = {},
|
||||
) {
|
||||
return buildNoticePdfModel({
|
||||
notice: n,
|
||||
baseline,
|
||||
site: overrides.site ?? SITE,
|
||||
settings: overrides.settings ?? SETTINGS,
|
||||
now: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
describe('buildNoticePdfModel', () => {
|
||||
it('keeps an empty veterinary office and an empty holder as null so the renderer prints FEHLT', () => {
|
||||
const model = build(notice(), null, {
|
||||
site: { holderName: null, holderAddress: ' ' },
|
||||
settings: {
|
||||
...SETTINGS,
|
||||
veterinaryOfficeName: '',
|
||||
veterinaryOfficeAddress: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.holder).toBeNull();
|
||||
expect(model.office).toEqual({ name: null, address: null });
|
||||
});
|
||||
|
||||
it('the first notice has the heading "Anzeige der Bienenhaltung (§ 1a BienSeuchV)" and no "vorher" column', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.title).toBe('Anzeige der Bienenhaltung (§ 1a BienSeuchV)');
|
||||
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker']);
|
||||
expect(model.rows[0].previous).toBeNull();
|
||||
expect(model.totals).toEqual({ current: 3, previous: null });
|
||||
expect(model.changes).toEqual([]);
|
||||
expect(model.referenceLine).toBe('Stand der Völker am 01.01.2027');
|
||||
});
|
||||
|
||||
it('the change notice shows the previous count per apiary and the totals', () => {
|
||||
const baseline = notice(
|
||||
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
|
||||
[apiary({ livingColonies: 2 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE', openKey: 'HERD_CHANGE' }),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.title).toBe(
|
||||
'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)',
|
||||
);
|
||||
expect(model.columns).toEqual(['Stand', 'Lage', 'Völker', 'vorher']);
|
||||
expect(model.rows[0]).toMatchObject({
|
||||
name: 'Heide',
|
||||
cells: ['3'],
|
||||
previous: 2,
|
||||
});
|
||||
expect(model.totals).toEqual({ current: 3, previous: 2 });
|
||||
});
|
||||
|
||||
it('lists new and abandoned apiaries under "Änderungen seit der Anzeige vom {sentOn}"', () => {
|
||||
const baseline = notice(
|
||||
{ kind: 'REGISTRATION', status: 'SENT', sentOn: new Date('2026-06-01') },
|
||||
[apiary(), apiary({ apiaryId: 'a2', name: 'Alt', livingColonies: 1 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE' }, [
|
||||
apiary(),
|
||||
apiary({ apiaryId: 'a3', name: 'Neu', livingColonies: 1 }),
|
||||
]),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.changes[0]).toBe(
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
);
|
||||
expect(model.changes).toContain('Stand „Neu“ neu');
|
||||
expect(model.changes).toContain('Stand „Alt“ aufgegeben');
|
||||
});
|
||||
|
||||
it('prints coordinates with five decimals only when there is no address', () => {
|
||||
const model = build(
|
||||
notice({}, [
|
||||
apiary({ apiaryId: 'a1', name: 'Mit', address: 'Weg 1' }),
|
||||
apiary({
|
||||
apiaryId: 'a2',
|
||||
name: 'Ohne',
|
||||
address: null,
|
||||
latitude: 51.3127,
|
||||
longitude: 9.4797,
|
||||
}),
|
||||
apiary({
|
||||
apiaryId: 'a3',
|
||||
name: 'Nichts',
|
||||
address: null,
|
||||
location: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(model.rows.map((r) => r.location)).toEqual([
|
||||
'Weg 1',
|
||||
'51.31270, 9.47970',
|
||||
'FEHLT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('the TSK model has one column per counted colony type and a sum', () => {
|
||||
const model = build(
|
||||
notice({ kind: 'TSK_REPORT', year: 2027, openKey: 'TSK_REPORT:2027' }),
|
||||
);
|
||||
|
||||
expect(model.title).toBe(
|
||||
'Zählnachweis Bienenvölker zum Stichtag 01.01.2027',
|
||||
);
|
||||
expect(model.columns).toEqual([
|
||||
'Stand',
|
||||
'Lage',
|
||||
'Wirtschaftsvolk',
|
||||
'Ableger',
|
||||
'Summe',
|
||||
]);
|
||||
expect(model.rows[0].cells).toEqual(['2', '1', '3']);
|
||||
expect(model.tskMemberNumber).toBe('TSK-9');
|
||||
expect(model.totals).toEqual({ current: 3, previous: null });
|
||||
});
|
||||
|
||||
it('the TSK model says "Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen." when counted after the reference day', () => {
|
||||
const late = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
|
||||
const onTime = build(
|
||||
notice({
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
countedAt: new Date('2026-12-31T23:30:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(late.countedNote).toBe(
|
||||
'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
|
||||
);
|
||||
expect(onTime.countedNote).toBe('Gezählt am 01.01.2027');
|
||||
});
|
||||
|
||||
it('the TSK footer says "Ersetzt nicht die Meldung an die Tierseuchenkasse."', () => {
|
||||
const model = build(notice({ kind: 'TSK_REPORT', year: 2027 }));
|
||||
|
||||
expect(model.footer).toContain(
|
||||
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
|
||||
);
|
||||
expect(model.footer).toContain(
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
);
|
||||
});
|
||||
|
||||
it('the registration footer has no TSK sentence and the TSK number stays out', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.footer).toEqual([
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
]);
|
||||
expect(model.tskMemberNumber).toBeNull();
|
||||
expect(model.countedNote).toBeNull();
|
||||
});
|
||||
|
||||
it('derives the place from the last holder address line and dates the letter in Berlin time', () => {
|
||||
const model = build(notice());
|
||||
|
||||
expect(model.place).toBe('Kassel');
|
||||
expect(model.dateLine).toBe('Kassel, 05.01.2027');
|
||||
});
|
||||
|
||||
it('prints FEHLT for the address when only the holder name is entered', () => {
|
||||
const model = build(notice(), null, {
|
||||
site: { holderName: 'Imkerei Muster', holderAddress: null },
|
||||
});
|
||||
|
||||
expect(model.holder).toBe('Imkerei Muster, FEHLT');
|
||||
expect(model.place).toBeNull();
|
||||
expect(model.dateLine).toBe('05.01.2027');
|
||||
});
|
||||
|
||||
it('uses the stored total on both sides and notes a corrected total', () => {
|
||||
const baseline = notice(
|
||||
{
|
||||
kind: 'REGISTRATION',
|
||||
status: 'SENT',
|
||||
sentOn: new Date('2026-06-01'),
|
||||
totalColonies: 5,
|
||||
},
|
||||
[apiary({ livingColonies: 2 })],
|
||||
);
|
||||
const model = build(
|
||||
notice({ kind: 'HERD_CHANGE', totalColonies: 7 }),
|
||||
baseline,
|
||||
);
|
||||
|
||||
expect(model.totals).toEqual({ current: 7, previous: 5 });
|
||||
expect(model.totalNote).toBe('Gesamtzahl berichtigt (Summe der Stände: 3)');
|
||||
expect(model.changes).toContain('Zahl der Völker: 5 auf 7');
|
||||
});
|
||||
|
||||
it('has no total note while the total matches the rows', () => {
|
||||
expect(build(notice()).totalNote).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import type {
|
||||
AuthorityNotice,
|
||||
AuthorityNoticeKind,
|
||||
ColonyType,
|
||||
ComplianceSettings,
|
||||
SiteSettings,
|
||||
} from '../../../generated/prisma';
|
||||
import { berlinDayOf } from '../../common/dates';
|
||||
import { formatDayDe } from '../../common/format-day';
|
||||
import type { HerdApiarySnapshot, HerdSnapshot } from '../herd-snapshot';
|
||||
|
||||
/** Printed in place of a mandatory particular that is not entered yet. */
|
||||
export const MISSING = 'FEHLT';
|
||||
|
||||
export interface NoticePdfModel {
|
||||
kind: AuthorityNoticeKind;
|
||||
title: string;
|
||||
holder: string | null;
|
||||
registrationNumber: string | null;
|
||||
tskMemberNumber: string | null;
|
||||
office: { name: string | null; address: string | null };
|
||||
place: string | null;
|
||||
dateLine: string;
|
||||
referenceLine: string;
|
||||
columns: string[];
|
||||
rows: {
|
||||
name: string;
|
||||
location: string;
|
||||
cells: string[];
|
||||
previous: number | null;
|
||||
}[];
|
||||
totals: { current: number; previous: number | null };
|
||||
changes: string[];
|
||||
/** Set when the stored total was corrected and differs from the rows. */
|
||||
totalNote: string | null;
|
||||
countedNote: string | null;
|
||||
footer: string[];
|
||||
}
|
||||
|
||||
export interface NoticePdfInput {
|
||||
notice: AuthorityNotice;
|
||||
baseline: AuthorityNotice | null;
|
||||
site: Pick<SiteSettings, 'holderName' | 'holderAddress'>;
|
||||
settings: Pick<
|
||||
ComplianceSettings,
|
||||
| 'veterinaryOfficeName'
|
||||
| 'veterinaryOfficeAddress'
|
||||
| 'registrationNumber'
|
||||
| 'tskMemberNumber'
|
||||
>;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
const TITLE_REGISTRATION = 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)';
|
||||
const TITLE_HERD_CHANGE = 'Änderungsanzeige Bienenhaltung (§ 1a BienSeuchV)';
|
||||
const TSK_DISCLAIMER = 'Ersetzt nicht die Meldung an die Tierseuchenkasse.';
|
||||
const COORDINATE_DECIMALS = 5;
|
||||
|
||||
/** In display order; the TSK columns follow it. */
|
||||
const COLONY_TYPES: readonly { type: ColonyType; label: string }[] = [
|
||||
{ type: 'PRODUCTION_COLONY', label: 'Wirtschaftsvolk' },
|
||||
{ type: 'NUCLEUS_COLONY', label: 'Ableger' },
|
||||
{ type: 'YOUNG_COLONY', label: 'Jungvolk' },
|
||||
{ type: 'CARETAKER_COLONY', label: 'Pflegevolk' },
|
||||
{ type: 'MATING_NUCLEUS', label: 'Begattungs-einheit' },
|
||||
{ type: 'RESERVE_COLONY', label: 'Reservevolk' },
|
||||
];
|
||||
|
||||
function clean(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** A stored day (midnight UTC) as dd.mm.yyyy. */
|
||||
function storedDay(date: Date): string {
|
||||
return formatDayDe(date.toISOString().slice(0, 10), true);
|
||||
}
|
||||
|
||||
/** An instant as the dd.mm.yyyy of its Berlin calendar day. */
|
||||
function berlinDay(instant: Date): string {
|
||||
return formatDayDe(berlinDayOf(instant), true);
|
||||
}
|
||||
|
||||
/** The town of a "Street 1, 34117 Town" address, when it has a postcode. */
|
||||
function placeOf(address: string | null): string | null {
|
||||
const match = address ? /\b\d{5}\s+([^,\n]+?)\s*$/.exec(address) : null;
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function locationOf(apiary: HerdApiarySnapshot): string {
|
||||
const address = clean(apiary.address);
|
||||
if (address) {
|
||||
return address;
|
||||
}
|
||||
if (apiary.latitude !== null && apiary.longitude !== null) {
|
||||
return `${apiary.latitude.toFixed(COORDINATE_DECIMALS)}, ${apiary.longitude.toFixed(COORDINATE_DECIMALS)}`;
|
||||
}
|
||||
return clean(apiary.location) ?? MISSING;
|
||||
}
|
||||
|
||||
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
|
||||
return (
|
||||
a.address === b.address &&
|
||||
a.location === b.location &&
|
||||
a.latitude === b.latitude &&
|
||||
a.longitude === b.longitude
|
||||
);
|
||||
}
|
||||
|
||||
function titleOf(notice: AuthorityNotice, hasBaseline: boolean): string {
|
||||
if (notice.kind === 'TSK_REPORT') {
|
||||
return `Zählnachweis Bienenvölker zum Stichtag ${storedDay(notice.referenceDate)}`;
|
||||
}
|
||||
return notice.kind === 'HERD_CHANGE' && hasBaseline
|
||||
? TITLE_HERD_CHANGE
|
||||
: TITLE_REGISTRATION;
|
||||
}
|
||||
|
||||
function changeLines(
|
||||
baseline: AuthorityNotice,
|
||||
before: HerdSnapshot,
|
||||
current: HerdSnapshot,
|
||||
currentTotal: number,
|
||||
): string[] {
|
||||
const old = new Map(before.apiaries.map((a) => [a.apiaryId, a]));
|
||||
const now = new Map(current.apiaries.map((a) => [a.apiaryId, a]));
|
||||
const lines = [
|
||||
`Änderungen seit der Anzeige vom ${baseline.sentOn ? storedDay(baseline.sentOn) : '–'}:`,
|
||||
];
|
||||
for (const apiary of current.apiaries) {
|
||||
const previous = old.get(apiary.apiaryId);
|
||||
if (!previous) {
|
||||
lines.push(`Stand „${apiary.name}“ neu`);
|
||||
} else if (!sameSite(previous, apiary)) {
|
||||
lines.push(`Stand „${apiary.name}“: Lage geändert`);
|
||||
}
|
||||
}
|
||||
for (const apiary of before.apiaries) {
|
||||
if (!now.has(apiary.apiaryId)) {
|
||||
lines.push(`Stand „${apiary.name}“ aufgegeben`);
|
||||
}
|
||||
}
|
||||
if (baseline.totalColonies !== currentTotal) {
|
||||
lines.push(
|
||||
`Zahl der Völker: ${baseline.totalColonies} auf ${currentTotal}`,
|
||||
);
|
||||
}
|
||||
return lines.length === 1 ? [...lines, 'keine'] : lines;
|
||||
}
|
||||
|
||||
function tskTypes(snapshot: HerdSnapshot) {
|
||||
return COLONY_TYPES.filter(({ type }) =>
|
||||
snapshot.apiaries.some((apiary) => (apiary.byType[type] ?? 0) > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function footerOf(notice: AuthorityNotice): string[] {
|
||||
const lines = [
|
||||
`Stand der Daten ${berlinDay(notice.updatedAt)} · erstellt mit ApiCore`,
|
||||
];
|
||||
return notice.kind === 'TSK_REPORT' ? [...lines, TSK_DISCLAIMER] : lines;
|
||||
}
|
||||
|
||||
function countedNoteOf(notice: AuthorityNotice): string | null {
|
||||
if (notice.kind !== 'TSK_REPORT') {
|
||||
return null;
|
||||
}
|
||||
const counted = `Gezählt am ${berlinDay(notice.countedAt)}`;
|
||||
const countedDay = berlinDayOf(notice.countedAt);
|
||||
const referenceDay = notice.referenceDate.toISOString().slice(0, 10);
|
||||
return countedDay > referenceDay
|
||||
? `${counted}, nicht am Stichtag. Bitte prüfen.`
|
||||
: counted;
|
||||
}
|
||||
|
||||
/** Pure: everything the notice PDF prints, without any drawing. */
|
||||
export function buildNoticePdfModel(input: NoticePdfInput): NoticePdfModel {
|
||||
const { notice, baseline, site, settings, now } = input;
|
||||
const current = notice.snapshot as unknown as HerdSnapshot;
|
||||
const isTsk = notice.kind === 'TSK_REPORT';
|
||||
const compare = notice.kind === 'HERD_CHANGE' && baseline !== null;
|
||||
const before = compare
|
||||
? (baseline.snapshot as unknown as HerdSnapshot)
|
||||
: null;
|
||||
const previousOf = (apiaryId: string): number | null =>
|
||||
before
|
||||
? (before.apiaries.find((a) => a.apiaryId === apiaryId)?.livingColonies ??
|
||||
0)
|
||||
: null;
|
||||
|
||||
const types = isTsk ? tskTypes(current) : [];
|
||||
const columns = isTsk
|
||||
? ['Stand', 'Lage', ...types.map((t) => t.label), 'Summe']
|
||||
: ['Stand', 'Lage', 'Völker', ...(compare ? ['vorher'] : [])];
|
||||
const rows = current.apiaries.map((apiary) => ({
|
||||
name: apiary.name,
|
||||
location: locationOf(apiary),
|
||||
cells: isTsk
|
||||
? [
|
||||
...types.map((t) => String(apiary.byType[t.type] ?? 0)),
|
||||
String(apiary.livingColonies),
|
||||
]
|
||||
: [String(apiary.livingColonies)],
|
||||
previous: previousOf(apiary.apiaryId),
|
||||
}));
|
||||
|
||||
const holderName = clean(site.holderName);
|
||||
const holderAddress = clean(site.holderAddress);
|
||||
// One of the two missing is a gap worth printing; both missing is null.
|
||||
const holder =
|
||||
holderName || holderAddress
|
||||
? `${holderName ?? MISSING}, ${holderAddress ?? MISSING}`
|
||||
: '';
|
||||
const place = placeOf(clean(site.holderAddress));
|
||||
const today = berlinDay(now);
|
||||
|
||||
return {
|
||||
kind: notice.kind,
|
||||
title: titleOf(notice, baseline !== null),
|
||||
holder: holder || null,
|
||||
registrationNumber: clean(settings.registrationNumber),
|
||||
tskMemberNumber: isTsk ? clean(settings.tskMemberNumber) : null,
|
||||
office: {
|
||||
name: clean(settings.veterinaryOfficeName),
|
||||
address: clean(settings.veterinaryOfficeAddress),
|
||||
},
|
||||
place,
|
||||
dateLine: place ? `${place}, ${today}` : today,
|
||||
referenceLine: `Stand der Völker am ${storedDay(notice.referenceDate)}`,
|
||||
columns,
|
||||
rows,
|
||||
totals: {
|
||||
current: notice.totalColonies,
|
||||
previous: compare ? baseline.totalColonies : null,
|
||||
},
|
||||
totalNote:
|
||||
notice.totalColonies === current.totalLiving
|
||||
? null
|
||||
: `Gesamtzahl berichtigt (Summe der Stände: ${current.totalLiving})`,
|
||||
changes:
|
||||
compare && before
|
||||
? changeLines(baseline, before, current, notice.totalColonies)
|
||||
: [],
|
||||
countedNote: countedNoteOf(notice),
|
||||
footer: footerOf(notice),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { PDF_MARGIN } from '../../export/pdf-table';
|
||||
import type { NoticePdfModel } from './authority-notice-model';
|
||||
import {
|
||||
noticeHeaderLines,
|
||||
renderAuthorityNoticePdf,
|
||||
} from './authority-notice-pdf';
|
||||
|
||||
function model(patch: Partial<NoticePdfModel> = {}): NoticePdfModel {
|
||||
return {
|
||||
kind: 'REGISTRATION',
|
||||
title: 'Anzeige der Bienenhaltung (§ 1a BienSeuchV)',
|
||||
holder: 'Imkerei Muster, Weg 2, 34117 Kassel',
|
||||
registrationNumber: 'DE-123',
|
||||
tskMemberNumber: null,
|
||||
office: { name: 'Veterinäramt Kassel', address: 'Amtsstr. 1' },
|
||||
place: 'Kassel',
|
||||
dateLine: 'Kassel, 05.01.2027',
|
||||
referenceLine: 'Stand der Völker am 01.01.2027',
|
||||
columns: ['Stand', 'Lage', 'Völker'],
|
||||
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: null }],
|
||||
totals: { current: 3, previous: null },
|
||||
changes: [],
|
||||
totalNote: null,
|
||||
countedNote: null,
|
||||
footer: ['Stand der Daten 03.01.2027 · erstellt mit ApiCore'],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('noticeHeaderLines', () => {
|
||||
it('prints FEHLT for an empty veterinary office and an empty holder', () => {
|
||||
const lines = noticeHeaderLines(
|
||||
model({ holder: null, office: { name: null, address: null } }),
|
||||
);
|
||||
|
||||
expect(lines.holder).toBe('FEHLT');
|
||||
expect(lines.office).toEqual(['FEHLT']);
|
||||
});
|
||||
|
||||
it('prints the registration number or a dash, and FEHLT for a missing TSK number', () => {
|
||||
expect(
|
||||
noticeHeaderLines(model({ registrationNumber: null })).identifiers,
|
||||
).toEqual(['Registriernummer: –']);
|
||||
expect(
|
||||
noticeHeaderLines(model({ kind: 'TSK_REPORT', tskMemberNumber: null }))
|
||||
.identifiers,
|
||||
).toContain('TSK-Mitgliedsnummer: FEHLT');
|
||||
});
|
||||
|
||||
it('leaves out the recipient block for the TSK report', () => {
|
||||
expect(noticeHeaderLines(model({ kind: 'TSK_REPORT' })).office).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderAuthorityNoticePdf', () => {
|
||||
it.each([
|
||||
['REGISTRATION', model()],
|
||||
[
|
||||
'HERD_CHANGE',
|
||||
model({
|
||||
kind: 'HERD_CHANGE',
|
||||
columns: ['Stand', 'Lage', 'Völker', 'vorher'],
|
||||
rows: [{ name: 'Heide', location: 'Weg 1', cells: ['3'], previous: 2 }],
|
||||
totals: { current: 3, previous: 2 },
|
||||
changes: [
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
'Stand „Neu“ neu',
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
'TSK_REPORT',
|
||||
model({
|
||||
kind: 'TSK_REPORT',
|
||||
tskMemberNumber: 'TSK-9',
|
||||
columns: ['Stand', 'Lage', 'Wirtschaftsvolk', 'Summe'],
|
||||
rows: [
|
||||
{
|
||||
name: 'Heide',
|
||||
location: 'Weg 1',
|
||||
cells: ['3', '3'],
|
||||
previous: null,
|
||||
},
|
||||
],
|
||||
countedNote: 'Gezählt am 02.01.2027, nicht am Stichtag. Bitte prüfen.',
|
||||
footer: [
|
||||
'Stand der Daten 03.01.2027 · erstellt mit ApiCore',
|
||||
'Ersetzt nicht die Meldung an die Tierseuchenkasse.',
|
||||
],
|
||||
}),
|
||||
],
|
||||
])('renders a PDF buffer that starts with %PDF for %s', async (_kind, m) => {
|
||||
const buffer = await renderAuthorityNoticePdf(m);
|
||||
|
||||
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||
});
|
||||
|
||||
it('renders a notice without apiaries', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({ rows: [], totals: { current: 0, previous: null } }),
|
||||
);
|
||||
|
||||
expect(buffer.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||
});
|
||||
});
|
||||
|
||||
function pageCount(buffer: Buffer): number {
|
||||
return (buffer.toString('latin1').match(/\/Type \/Page(?!s)/g) ?? []).length;
|
||||
}
|
||||
|
||||
function manyRows(count: number): NoticePdfModel['rows'] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
name: `Bienenstand Müßiggang-Süd ${index + 1} am Fließgewässer`,
|
||||
location:
|
||||
'Äußere Gartenallee 12, Gemarkung Großalmerode, 37247 Großalmerode',
|
||||
cells: ['8'],
|
||||
previous: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('notice PDF layout', () => {
|
||||
let restoreText = (): void => undefined;
|
||||
afterEach(() => restoreText());
|
||||
|
||||
it('keeps 15 apiaries with long addresses on one portrait page, signature included', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({ rows: manyRows(15), totals: { current: 120, previous: null } }),
|
||||
);
|
||||
|
||||
expect(pageCount(buffer)).toBe(1);
|
||||
});
|
||||
|
||||
it('prints the notes and the footer at the left margin after the table', async () => {
|
||||
const calls: { text: string; x: number }[] = [];
|
||||
type TextFn = (this: { x: number }, ...args: unknown[]) => unknown;
|
||||
const proto = PDFDocument.prototype as { text: TextFn };
|
||||
const original = proto.text;
|
||||
proto.text = function (this: { x: number }, ...args: unknown[]) {
|
||||
calls.push({
|
||||
text: String(args[0]),
|
||||
x: typeof args[1] === 'number' ? args[1] : this.x,
|
||||
});
|
||||
return original.apply(this, args);
|
||||
};
|
||||
restoreText = () => {
|
||||
proto.text = original;
|
||||
};
|
||||
|
||||
await renderAuthorityNoticePdf(
|
||||
model({
|
||||
rows: manyRows(3),
|
||||
totalNote: 'Gesamtzahl berichtigt (Summe der Stände: 24)',
|
||||
changes: [
|
||||
'Änderungen seit der Anzeige vom 01.06.2026:',
|
||||
'Stand „A“ neu',
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
for (const prefix of [
|
||||
'Gesamtzahl berichtigt',
|
||||
'Änderungen seit',
|
||||
'– Stand',
|
||||
'Stand der Daten',
|
||||
]) {
|
||||
const call = calls.find((c) => c.text.startsWith(prefix));
|
||||
expect(call?.x).toBe(PDF_MARGIN);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the TSK report landscape with room for the location column', async () => {
|
||||
const buffer = await renderAuthorityNoticePdf(
|
||||
model({
|
||||
kind: 'TSK_REPORT',
|
||||
columns: [
|
||||
'Stand',
|
||||
'Lage',
|
||||
'Wirtschaftsvolk',
|
||||
'Begattungs-einheit',
|
||||
'Summe',
|
||||
],
|
||||
rows: manyRows(15).map((row) => ({ ...row, cells: ['5', '3', '8'] })),
|
||||
totals: { current: 120, previous: null },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buffer.toString('latin1')).toContain('/MediaBox [0 0 841.89');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
PdfColumn,
|
||||
drawTable,
|
||||
orDash,
|
||||
} from '../../export/pdf-table';
|
||||
import { MISSING, NoticePdfModel } from './authority-notice-model';
|
||||
|
||||
const NAME_WIDTH = 110;
|
||||
const MIN_NUMBER_WIDTH = 40;
|
||||
const NUMBER_PADDING = 8;
|
||||
const SIGNATURE_LINE_WIDTH = 200;
|
||||
|
||||
export interface NoticeHeaderLines {
|
||||
holder: string;
|
||||
identifiers: string[];
|
||||
/** The addressee block; empty for the TSK report (another recipient). */
|
||||
office: string[];
|
||||
}
|
||||
|
||||
/** Pure: the header texts, with FEHLT for every mandatory gap. */
|
||||
export function noticeHeaderLines(model: NoticePdfModel): NoticeHeaderLines {
|
||||
const isTsk = model.kind === 'TSK_REPORT';
|
||||
const identifiers = [`Registriernummer: ${orDash(model.registrationNumber)}`];
|
||||
if (isTsk) {
|
||||
identifiers.push(
|
||||
`TSK-Mitgliedsnummer: ${model.tskMemberNumber ?? MISSING}`,
|
||||
);
|
||||
}
|
||||
const office = isTsk
|
||||
? []
|
||||
: [
|
||||
model.office.name ?? MISSING,
|
||||
...(model.office.address ? [model.office.address] : []),
|
||||
];
|
||||
return { holder: model.holder ?? MISSING, identifiers, office };
|
||||
}
|
||||
|
||||
/** Room for the widest word of a header (it may break at a hyphen). */
|
||||
function numberWidth(doc: PDFKit.PDFDocument, header: string): number {
|
||||
doc.font('Helvetica-Bold').fontSize(8);
|
||||
const widest = Math.max(
|
||||
...header.split('-').map((part) => doc.widthOfString(part)),
|
||||
);
|
||||
return Math.max(MIN_NUMBER_WIDTH, Math.ceil(widest) + NUMBER_PADDING);
|
||||
}
|
||||
|
||||
function tableColumns(
|
||||
doc: PDFKit.PDFDocument,
|
||||
model: NoticePdfModel,
|
||||
): PdfColumn[] {
|
||||
const numeric = model.columns.slice(2).map((header) => ({
|
||||
header,
|
||||
width: numberWidth(doc, header),
|
||||
align: 'right' as const,
|
||||
}));
|
||||
const used = numeric.reduce((sum, column) => sum + column.width, 0);
|
||||
return [
|
||||
{ header: model.columns[0], width: NAME_WIDTH },
|
||||
{
|
||||
header: model.columns[1],
|
||||
width: doc.page.width - 2 * PDF_MARGIN - NAME_WIDTH - used,
|
||||
},
|
||||
...numeric,
|
||||
];
|
||||
}
|
||||
|
||||
/** Sums of the numeric columns, so the TSK table totals each colony type. */
|
||||
function totalsRow(model: NoticePdfModel): string[] {
|
||||
const isTsk = model.kind === 'TSK_REPORT';
|
||||
if (isTsk) {
|
||||
const typeCount = model.columns.length - 3;
|
||||
const sums = Array.from({ length: typeCount }, (_, index) =>
|
||||
model.rows.reduce((sum, row) => sum + Number(row.cells[index]), 0),
|
||||
);
|
||||
return ['Summe', '', ...sums.map(String), String(model.totals.current)];
|
||||
}
|
||||
return [
|
||||
'Summe',
|
||||
'',
|
||||
String(model.totals.current),
|
||||
...(model.totals.previous === null ? [] : [String(model.totals.previous)]),
|
||||
];
|
||||
}
|
||||
|
||||
function bodyRows(model: NoticePdfModel): string[][] {
|
||||
const rows = model.rows.map((row) => [
|
||||
row.name,
|
||||
row.location,
|
||||
...row.cells,
|
||||
...(row.previous === null || model.kind === 'TSK_REPORT'
|
||||
? []
|
||||
: [String(row.previous)]),
|
||||
]);
|
||||
return rows.length === 0 ? rows : [...rows, totalsRow(model)];
|
||||
}
|
||||
|
||||
function drawHeader(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
|
||||
const header = noticeHeaderLines(model);
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(10)
|
||||
.text(header.holder, PDF_MARGIN, PDF_MARGIN);
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
for (const line of header.identifiers) writeLine(doc, line);
|
||||
if (header.office.length > 0) {
|
||||
doc.moveDown(1);
|
||||
doc.text(`An ${header.office[0]}`);
|
||||
for (const line of header.office.slice(1)) doc.text(` ${line}`);
|
||||
}
|
||||
doc.moveDown(0.6);
|
||||
doc.text(model.dateLine, { align: 'right' });
|
||||
doc.moveDown(0.6);
|
||||
doc.font('Helvetica-Bold').fontSize(13).text(model.title);
|
||||
doc.font('Helvetica').fontSize(9).text(model.referenceLine);
|
||||
doc.moveDown(0.6);
|
||||
}
|
||||
|
||||
/** A paragraph at the left margin; the table leaves x at its last column. */
|
||||
function writeLine(doc: PDFKit.PDFDocument, text: string): void {
|
||||
doc.text(text, PDF_MARGIN, doc.y, {
|
||||
width: doc.page.width - 2 * PDF_MARGIN,
|
||||
});
|
||||
}
|
||||
|
||||
function drawNotes(doc: PDFKit.PDFDocument, model: NoticePdfModel): void {
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
if (model.totalNote) {
|
||||
writeLine(doc, model.totalNote);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
if (model.changes.length > 0) {
|
||||
doc.font('Helvetica-Bold');
|
||||
writeLine(doc, model.changes[0]);
|
||||
doc.font('Helvetica');
|
||||
for (const line of model.changes.slice(1)) writeLine(doc, `– ${line}`);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
if (model.countedNote) {
|
||||
writeLine(doc, model.countedNote);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSignatureAndFooter(
|
||||
doc: PDFKit.PDFDocument,
|
||||
model: NoticePdfModel,
|
||||
): void {
|
||||
if (doc.y > doc.page.height - PDF_MARGIN - 110) doc.addPage();
|
||||
doc.moveDown(3);
|
||||
const y = doc.y;
|
||||
doc
|
||||
.moveTo(PDF_MARGIN, y)
|
||||
.lineTo(PDF_MARGIN + SIGNATURE_LINE_WIDTH, y)
|
||||
.lineWidth(0.5)
|
||||
.strokeColor('#000000')
|
||||
.stroke();
|
||||
doc
|
||||
.font('Helvetica')
|
||||
.fontSize(8)
|
||||
.text('Unterschrift', PDF_MARGIN, y + 3);
|
||||
doc.moveDown(1.5);
|
||||
doc.fillColor('#555555');
|
||||
for (const line of model.footer) writeLine(doc, line);
|
||||
doc.fillColor('#000000');
|
||||
}
|
||||
|
||||
export function renderAuthorityNoticePdf(
|
||||
model: NoticePdfModel,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Seven colony-type columns need the width of a landscape page.
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: model.kind === 'TSK_REPORT' ? 'landscape' : 'portrait',
|
||||
margin: PDF_MARGIN,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
drawHeader(doc, model);
|
||||
drawTable(doc, tableColumns(doc, model), bodyRows(model), {
|
||||
boldLastRow: true,
|
||||
});
|
||||
doc.x = PDF_MARGIN;
|
||||
drawNotes(doc, model);
|
||||
drawSignatureAndFooter(doc, model);
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuditLogService } from '../../audit/audit-log.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||
import { AcknowledgementsService } from './acknowledgements.service';
|
||||
import { ReadinessService } from './readiness.service';
|
||||
import type { ReadinessItem, ReadinessRule } from './readiness.types';
|
||||
|
||||
jest.mock('../../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const lockMock = lockRowForUpdate as jest.Mock;
|
||||
const rules: ReadinessRule[] = [];
|
||||
const FINGERPRINT = 'a'.repeat(64);
|
||||
const NOTE = 'Geprüft, alles in Ordnung.';
|
||||
|
||||
function rule(patch: Partial<ReadinessRule>): ReadinessRule {
|
||||
return {
|
||||
key: 'soft',
|
||||
area: 'ETIKETTEN',
|
||||
severity: 'PRUEFEN',
|
||||
title: 'Weicher Punkt',
|
||||
legalBasis: null,
|
||||
acknowledgeable: true,
|
||||
evaluate: () => [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function item(patch: Partial<ReadinessItem> = {}): ReadinessItem {
|
||||
return {
|
||||
key: 'soft',
|
||||
area: 'ETIKETTEN',
|
||||
severity: 'PRUEFEN',
|
||||
subjectId: 'global',
|
||||
title: 'Weicher Punkt',
|
||||
detail: 'Prüfen.',
|
||||
legalBasis: null,
|
||||
acknowledgeable: true,
|
||||
fingerprint: FINGERPRINT,
|
||||
subjects: [],
|
||||
more: 0,
|
||||
state: 'OPEN',
|
||||
acknowledgement: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function dto(patch: Record<string, string> = {}) {
|
||||
return {
|
||||
itemKey: 'soft',
|
||||
subjectId: 'global',
|
||||
fingerprint: FINGERPRINT,
|
||||
note: NOTE,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AcknowledgementsService', () => {
|
||||
let service: AcknowledgementsService;
|
||||
let readiness: { evaluate: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
complianceAcknowledgement: {
|
||||
create: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findUniqueOrThrow: jest.Mock;
|
||||
};
|
||||
$executeRaw: jest.Mock;
|
||||
};
|
||||
|
||||
function reportWith(...items: ReadinessItem[]) {
|
||||
readiness.evaluate.mockResolvedValue({
|
||||
status: 'OPEN',
|
||||
generatedAt: 'x',
|
||||
openCount: items.length,
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rules.splice(
|
||||
0,
|
||||
rules.length,
|
||||
rule({}),
|
||||
rule({ key: 'must', severity: 'PFLICHT', acknowledgeable: false }),
|
||||
);
|
||||
lockMock.mockReset().mockResolvedValue(true);
|
||||
tx = {
|
||||
complianceAcknowledgement: {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
id: 'ack1',
|
||||
note: NOTE,
|
||||
createdAt: new Date('2027-03-15T10:00:00.000Z'),
|
||||
user: { name: 'Imker Eins' },
|
||||
}),
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
},
|
||||
$executeRaw: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn((callback: (client: unknown) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
} as unknown as PrismaService;
|
||||
readiness = { evaluate: jest.fn() };
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new AcknowledgementsService(
|
||||
prisma,
|
||||
auditLog as unknown as AuditLogService,
|
||||
readiness as unknown as ReadinessService,
|
||||
rules,
|
||||
);
|
||||
reportWith(item());
|
||||
});
|
||||
|
||||
it('writes the acknowledgement and the audit entry in one transaction and returns the item acknowledged', async () => {
|
||||
const result = await service.create(dto(), 'u1');
|
||||
|
||||
expect(tx.complianceAcknowledgement.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
itemKey: 'soft',
|
||||
subjectId: 'global',
|
||||
fingerprint: FINGERPRINT,
|
||||
note: NOTE,
|
||||
userId: 'u1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ComplianceAcknowledgement',
|
||||
entityId: 'ack1',
|
||||
action: 'CREATE',
|
||||
userId: 'u1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
key: 'soft',
|
||||
state: 'ACKNOWLEDGED',
|
||||
acknowledgement: {
|
||||
id: 'ack1',
|
||||
note: NOTE,
|
||||
userName: 'Imker Eins',
|
||||
createdAt: '2027-03-15T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a stale fingerprint with 409', async () => {
|
||||
await expect(
|
||||
service.create(dto({ fingerprint: 'b'.repeat(64) }), 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
expect(tx.complianceAcknowledgement.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an item that is not open (gone or done) with 409', async () => {
|
||||
reportWith(item({ state: 'DONE' }));
|
||||
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
reportWith();
|
||||
await expect(service.create(dto(), 'u1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a PFLICHT item and an unknown item key with 400', async () => {
|
||||
await expect(
|
||||
service.create(dto({ itemKey: 'must' }), 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create(dto({ itemKey: 'nope' }), 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(readiness.evaluate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a note under 10 characters is a 400', async () => {
|
||||
await expect(
|
||||
service.create(dto({ note: ' kurz ' }), 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create(dto({ note: 'x'.repeat(2001) }), 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('revoke locks the acknowledgement row, sets revokedAt in SQL and audits', async () => {
|
||||
const row = {
|
||||
id: 'ack1',
|
||||
itemKey: 'soft',
|
||||
subjectId: 'global',
|
||||
fingerprint: FINGERPRINT,
|
||||
note: NOTE,
|
||||
revokedAt: null,
|
||||
};
|
||||
tx.complianceAcknowledgement.findUnique.mockResolvedValue(row);
|
||||
tx.complianceAcknowledgement.findUniqueOrThrow.mockResolvedValue({
|
||||
...row,
|
||||
revokedAt: new Date('2027-03-16T00:00:00.000Z'),
|
||||
revokedByUserId: 'u2',
|
||||
});
|
||||
|
||||
await service.revoke('ack1', 'u2');
|
||||
|
||||
expect(lockMock).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'ComplianceAcknowledgement',
|
||||
'ack1',
|
||||
);
|
||||
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ComplianceAcknowledgement',
|
||||
entityId: 'ack1',
|
||||
action: 'UPDATE',
|
||||
userId: 'u2',
|
||||
beforeData: expect.objectContaining({ revokedAt: null }) as unknown,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('revoke refuses an already revoked acknowledgement and a missing one', async () => {
|
||||
tx.complianceAcknowledgement.findUnique.mockResolvedValue({
|
||||
id: 'ack1',
|
||||
revokedAt: new Date('2027-03-01T00:00:00.000Z'),
|
||||
});
|
||||
await expect(service.revoke('ack1', 'u2')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
||||
|
||||
lockMock.mockResolvedValue(false);
|
||||
await expect(service.revoke('gone', 'u2')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuditLogService } from '../../audit/audit-log.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||
import type { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
|
||||
import { ReadinessService } from './readiness.service';
|
||||
import type { ReadinessItem, ReadinessRule } from './readiness.types';
|
||||
import { READINESS_RULES_TOKEN } from './rules';
|
||||
|
||||
const AUDIT_ENTITY = 'ComplianceAcknowledgement';
|
||||
export const MIN_NOTE_LENGTH = 10;
|
||||
export const MAX_NOTE_LENGTH = 2000;
|
||||
const NO_PERMISSIONS: ReadonlySet<string> = new Set();
|
||||
|
||||
const STALE_MESSAGE = 'Der Punkt hat sich geändert. Bitte neu laden.';
|
||||
|
||||
@Injectable()
|
||||
export class AcknowledgementsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly readiness: ReadinessService,
|
||||
@Inject(READINESS_RULES_TOKEN)
|
||||
private readonly rules: readonly ReadinessRule[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Acknowledges one open item as checked. The fingerprint must equal the one
|
||||
* of the item as it is computed now (409 otherwise). `permissions` only
|
||||
* decides which subjects the returned item shows.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateAcknowledgementDto,
|
||||
userId: string,
|
||||
permissions: ReadonlySet<string> = NO_PERMISSIONS,
|
||||
): Promise<ReadinessItem> {
|
||||
const note = dto.note.trim();
|
||||
if (note.length < MIN_NOTE_LENGTH || note.length > MAX_NOTE_LENGTH) {
|
||||
throw new BadRequestException(
|
||||
`Die Notiz braucht ${MIN_NOTE_LENGTH} bis ${MAX_NOTE_LENGTH} Zeichen.`,
|
||||
);
|
||||
}
|
||||
const rule = this.rules.find((entry) => entry.key === dto.itemKey);
|
||||
if (!rule) {
|
||||
throw new BadRequestException('Diesen Prüfpunkt gibt es nicht.');
|
||||
}
|
||||
if (!rule.acknowledgeable) {
|
||||
throw new BadRequestException(
|
||||
'Dieser Prüfpunkt lässt sich nicht quittieren. Er muss behoben werden.',
|
||||
);
|
||||
}
|
||||
const report = await this.readiness.evaluate(new Date(), permissions);
|
||||
const item = report.items.find(
|
||||
(entry) => entry.key === dto.itemKey && entry.subjectId === dto.subjectId,
|
||||
);
|
||||
if (!item || item.state === 'DONE') {
|
||||
throw new ConflictException(STALE_MESSAGE);
|
||||
}
|
||||
if (item.fingerprint !== dto.fingerprint) {
|
||||
throw new ConflictException(STALE_MESSAGE);
|
||||
}
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.complianceAcknowledgement.create({
|
||||
data: {
|
||||
itemKey: item.key,
|
||||
subjectId: item.subjectId,
|
||||
fingerprint: item.fingerprint,
|
||||
note,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: row.id,
|
||||
action: 'CREATE',
|
||||
userId,
|
||||
afterData: {
|
||||
itemKey: item.key,
|
||||
subjectId: item.subjectId,
|
||||
fingerprint: item.fingerprint,
|
||||
note,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return row;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
state: 'ACKNOWLEDGED',
|
||||
acknowledgement: {
|
||||
id: created.id,
|
||||
note: created.note,
|
||||
userName: created.user.name,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Takes an acknowledgement back; the item is open again. */
|
||||
async revoke(id: string, userId: string): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (!(await lockRowForUpdate(tx, AUDIT_ENTITY, id))) {
|
||||
throw new NotFoundException('Die Quittung gibt es nicht.');
|
||||
}
|
||||
// Read after the lock: two parallel revokes queue up, the second sees
|
||||
// the first one's result.
|
||||
const before = await tx.complianceAcknowledgement.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException('Die Quittung gibt es nicht.');
|
||||
}
|
||||
if (before.revokedAt) {
|
||||
throw new ConflictException('Die Quittung ist schon zurückgenommen.');
|
||||
}
|
||||
await tx.$executeRaw`
|
||||
UPDATE "ComplianceAcknowledgement"
|
||||
SET "revokedAt" = now(), "revokedByUserId" = ${userId}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
const after = await tx.complianceAcknowledgement.findUniqueOrThrow({
|
||||
where: { id },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
const KEY_PATTERN = /^[a-z0-9-]+$/;
|
||||
const SUBJECT_PATTERN = /^[A-Za-z0-9_:.-]+$/;
|
||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
export class CreateAcknowledgementDto {
|
||||
@ApiProperty({ example: 'harvest-withdrawal' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@Matches(KEY_PATTERN)
|
||||
itemKey!: string;
|
||||
|
||||
@ApiProperty({ example: 'global', description: 'subjectId des Punktes' })
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@Matches(SUBJECT_PATTERN)
|
||||
subjectId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Fingerabdruck des Punktes aus der Auswertung' })
|
||||
@IsString()
|
||||
@Matches(FINGERPRINT_PATTERN)
|
||||
fingerprint!: string;
|
||||
|
||||
@ApiProperty({ description: 'Notiz, 10 bis 2000 Zeichen' })
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { fingerprintOf } from './fingerprint';
|
||||
|
||||
describe('fingerprintOf', () => {
|
||||
it('is stable for the same parts regardless of key order and differs when a value changes', () => {
|
||||
const a = fingerprintOf({
|
||||
year: 2027,
|
||||
ids: ['x', 'y'],
|
||||
nested: { a: 1, b: 2 },
|
||||
});
|
||||
const sameOtherOrder = fingerprintOf({
|
||||
nested: { b: 2, a: 1 },
|
||||
ids: ['x', 'y'],
|
||||
year: 2027,
|
||||
});
|
||||
const changedValue = fingerprintOf({
|
||||
year: 2028,
|
||||
ids: ['x', 'y'],
|
||||
nested: { a: 1, b: 2 },
|
||||
});
|
||||
|
||||
expect(a).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sameOtherOrder).toBe(a);
|
||||
expect(changedValue).not.toBe(a);
|
||||
});
|
||||
|
||||
it('keeps the order of an array and treats dates by their ISO text', () => {
|
||||
expect(fingerprintOf({ ids: ['x', 'y'] })).not.toBe(
|
||||
fingerprintOf({ ids: ['y', 'x'] }),
|
||||
);
|
||||
expect(fingerprintOf({ at: new Date('2027-01-01T00:00:00.000Z') })).toBe(
|
||||
fingerprintOf({ at: '2027-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores undefined properties and accepts null and plain values', () => {
|
||||
expect(fingerprintOf({ a: 1, b: undefined })).toBe(fingerprintOf({ a: 1 }));
|
||||
expect(fingerprintOf(null)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(fingerprintOf(undefined)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(fingerprintOf(5)).not.toBe(fingerprintOf('5'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// JSON with sorted keys at every level, so the same facts always give the same
|
||||
// text. Dates become their ISO text first (toJSON); undefined is dropped.
|
||||
function canonical(value: unknown): unknown {
|
||||
const plain: unknown =
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof (value as { toJSON?: unknown }).toJSON === 'function'
|
||||
? (value as { toJSON(): unknown }).toJSON()
|
||||
: value;
|
||||
if (Array.isArray(plain)) {
|
||||
return plain.map((entry) => canonical(entry) ?? null);
|
||||
}
|
||||
if (plain !== null && typeof plain === 'object') {
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(plain).sort()) {
|
||||
const entry = canonical((plain as Record<string, unknown>)[key]);
|
||||
if (entry !== undefined) {
|
||||
sorted[key] = entry;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
return plain;
|
||||
}
|
||||
|
||||
/** sha256 (hex) of the canonical JSON of `parts`. */
|
||||
export function fingerprintOf(parts: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify(canonical(parts)) ?? 'null')
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type {
|
||||
ApiaryRestriction,
|
||||
AuthorityNotice,
|
||||
ComplianceSettings,
|
||||
SiteSettings,
|
||||
} from '../../../generated/prisma';
|
||||
import { berlinToday, dayToDate } from '../../common/dates';
|
||||
import type { DbClient } from '../../prisma/db-client';
|
||||
import { herdStatusIn, type HerdStatus } from '../herd-status';
|
||||
|
||||
const SITE_SETTINGS_ID = 'singleton';
|
||||
/** TSK reports of this year and the one before matter for the readiness. */
|
||||
const TSK_LOOKBACK_YEARS = 1;
|
||||
const MAX_TSK_NOTICES = 100;
|
||||
const MAX_RESTRICTIONS = 500;
|
||||
|
||||
/** A notice without its snapshot (addresses, large): the rules never read it. */
|
||||
export type ReadinessNotice = Omit<AuthorityNotice, 'snapshot'>;
|
||||
|
||||
export type ReadinessRestriction = ApiaryRestriction & {
|
||||
apiary: { id: string; name: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the rules read, loaded once per evaluation. Task 13 adds the
|
||||
* fields of the other rule groups.
|
||||
*/
|
||||
export interface ReadinessContext {
|
||||
now: Date;
|
||||
/** The Berlin calendar day of `now` (YYYY-MM-DD). */
|
||||
today: string;
|
||||
site: SiteSettings;
|
||||
settings: ComplianceSettings;
|
||||
herd: HerdStatus;
|
||||
notices: ReadinessNotice[];
|
||||
/** The restrictions active on `today`. */
|
||||
restrictions: ReadinessRestriction[];
|
||||
}
|
||||
|
||||
/** Before the first save there is no row: every field is empty. */
|
||||
const EMPTY_SITE_SETTINGS: SiteSettings = {
|
||||
id: SITE_SETTINGS_ID,
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: null,
|
||||
holderAddress: null,
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
originCountry: '',
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
const NOTICE_COLUMNS = {
|
||||
id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
year: true,
|
||||
referenceDate: true,
|
||||
countedAt: true,
|
||||
totalColonies: true,
|
||||
sentOn: true,
|
||||
sentVia: true,
|
||||
reference: true,
|
||||
notes: true,
|
||||
openKey: true,
|
||||
createdByUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
/** A read: nothing here writes (the settings rows are seeded by migrations). */
|
||||
export async function loadReadinessContext(
|
||||
db: DbClient,
|
||||
now: Date,
|
||||
): Promise<ReadinessContext> {
|
||||
const today = berlinToday(now);
|
||||
const day = dayToDate(today);
|
||||
const firstTskYear = Number(today.slice(0, 4)) - TSK_LOOKBACK_YEARS;
|
||||
const [site, herd, sentRegistration, draftRegistration, tskReports, active] =
|
||||
await Promise.all([
|
||||
db.siteSettings.findUnique({ where: { id: SITE_SETTINGS_ID } }),
|
||||
herdStatusIn(db, now),
|
||||
// The registrations have their own queries, so an old registration can
|
||||
// never fall out of the window meant for the TSK reports.
|
||||
db.authorityNotice.findFirst({
|
||||
where: { kind: 'REGISTRATION', status: 'SENT' },
|
||||
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: NOTICE_COLUMNS,
|
||||
}),
|
||||
db.authorityNotice.findFirst({
|
||||
where: { kind: 'REGISTRATION', status: 'DRAFT' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: NOTICE_COLUMNS,
|
||||
}),
|
||||
db.authorityNotice.findMany({
|
||||
where: {
|
||||
kind: 'TSK_REPORT',
|
||||
status: { not: 'CANCELLED' },
|
||||
year: { gte: firstTskYear },
|
||||
},
|
||||
select: NOTICE_COLUMNS,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: MAX_TSK_NOTICES,
|
||||
}),
|
||||
db.apiaryRestriction.findMany({
|
||||
where: {
|
||||
startsOn: { lte: day },
|
||||
OR: [{ endsOn: null }, { endsOn: { gte: day } }],
|
||||
},
|
||||
include: { apiary: { select: { id: true, name: true } } },
|
||||
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
|
||||
take: MAX_RESTRICTIONS,
|
||||
}),
|
||||
]);
|
||||
const registrations = [sentRegistration, draftRegistration].filter(
|
||||
(entry): entry is ReadinessNotice => entry !== null,
|
||||
);
|
||||
return {
|
||||
now,
|
||||
today,
|
||||
site: site ?? EMPTY_SITE_SETTINGS,
|
||||
settings: herd.settings,
|
||||
herd,
|
||||
notices: [...registrations, ...tskReports],
|
||||
restrictions: active,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { WorkerModule } from '../../worker/worker.module';
|
||||
import { ReadinessController } from './readiness.controller';
|
||||
import { ReadinessModule } from './readiness.module';
|
||||
|
||||
// The controller injects the permission resolver: the worker must not load it.
|
||||
async function reachable(root: unknown): Promise<Set<unknown>> {
|
||||
const seen = new Set<unknown>();
|
||||
async function visit(entry: unknown): Promise<void> {
|
||||
const resolved: unknown = await entry;
|
||||
if (
|
||||
resolved === null ||
|
||||
(typeof resolved !== 'object' && typeof resolved !== 'function')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if ('forwardRef' in resolved) {
|
||||
return visit((resolved as { forwardRef: () => unknown }).forwardRef());
|
||||
}
|
||||
if ('module' in resolved) {
|
||||
const dynamic = resolved as { module: unknown; imports?: unknown[] };
|
||||
await visit(dynamic.module);
|
||||
for (const imported of dynamic.imports ?? []) {
|
||||
await visit(imported);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (seen.has(resolved)) {
|
||||
return;
|
||||
}
|
||||
seen.add(resolved);
|
||||
const imports =
|
||||
(Reflect.getMetadata('imports', resolved) as unknown[] | undefined) ?? [];
|
||||
for (const imported of imports) {
|
||||
await visit(imported);
|
||||
}
|
||||
}
|
||||
await visit(root);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('worker module graph', () => {
|
||||
it('contains neither the ReadinessModule nor the ReadinessController', async () => {
|
||||
const modules = await reachable(WorkerModule);
|
||||
|
||||
expect(modules.size).toBeGreaterThan(5);
|
||||
expect(modules).not.toContain(ReadinessModule);
|
||||
for (const module of modules) {
|
||||
const controllers =
|
||||
(Reflect.getMetadata('controllers', module as object) as
|
||||
unknown[] | undefined) ?? [];
|
||||
expect(controllers).not.toContain(ReadinessController);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Get, Param, Post } 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 { PermissionResolverService } from '../../auth/permissions/permission-resolver.service';
|
||||
import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../../common/idempotency/idempotent.decorator';
|
||||
import { AcknowledgementsService } from './acknowledgements.service';
|
||||
import { CreateAcknowledgementDto } from './dto/create-acknowledgement.dto';
|
||||
import { ReadinessService } from './readiness.service';
|
||||
|
||||
@ApiTags('compliance')
|
||||
@ApiBearerAuth()
|
||||
@Controller('compliance')
|
||||
export class ReadinessController {
|
||||
constructor(
|
||||
private readonly readiness: ReadinessService,
|
||||
private readonly acknowledgements: AcknowledgementsService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get('readiness')
|
||||
async evaluate(@CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.readiness.evaluate(
|
||||
new Date(),
|
||||
await this.permissions.getPermissions(actor.id),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:read')
|
||||
@Get('readiness/summary')
|
||||
summary() {
|
||||
return this.readiness.summary(new Date());
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Idempotent()
|
||||
@Post('acknowledgements')
|
||||
async acknowledge(
|
||||
@Body() dto: CreateAcknowledgementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.acknowledgements.create(
|
||||
dto,
|
||||
actor.id,
|
||||
await this.permissions.getPermissions(actor.id),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Post('acknowledgements/:id/revoke')
|
||||
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.acknowledgements.revoke(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ComplianceModule } from '../compliance.module';
|
||||
import { READINESS_RULES, READINESS_RULES_TOKEN } from './rules';
|
||||
import { AcknowledgementsService } from './acknowledgements.service';
|
||||
import { ReadinessController } from './readiness.controller';
|
||||
import { ReadinessService } from './readiness.service';
|
||||
|
||||
// Not for WorkerModule: the controller injects the permission resolver (to
|
||||
// hide the subjects a caller may not see). The services themselves do not.
|
||||
@Module({
|
||||
imports: [ComplianceModule],
|
||||
controllers: [ReadinessController],
|
||||
providers: [
|
||||
ReadinessService,
|
||||
AcknowledgementsService,
|
||||
{ provide: READINESS_RULES_TOKEN, useValue: READINESS_RULES },
|
||||
],
|
||||
exports: [ReadinessService],
|
||||
})
|
||||
export class ReadinessModule {}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { fingerprintOf } from './fingerprint';
|
||||
import { loadReadinessContext } from './readiness-context';
|
||||
import { ReadinessService } from './readiness.service';
|
||||
import type {
|
||||
ReadinessFinding,
|
||||
ReadinessRule,
|
||||
ReadinessSubject,
|
||||
} from './readiness.types';
|
||||
import { readinessContext } from './rules/rule-spec-helpers';
|
||||
|
||||
jest.mock('./readiness-context', () => ({
|
||||
loadReadinessContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const NOW = new Date('2027-03-15T10:00:00.000Z');
|
||||
const loadContextMock = loadReadinessContext as jest.Mock;
|
||||
const rules: ReadinessRule[] = [];
|
||||
|
||||
function fakeRule(
|
||||
patch: Partial<ReadinessRule> & { findings?: ReadinessFinding[] },
|
||||
): ReadinessRule {
|
||||
const { findings = [], ...rest } = patch;
|
||||
return {
|
||||
key: 'fake',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Fake-Regel',
|
||||
legalBasis: 'Norm',
|
||||
acknowledgeable: false,
|
||||
evaluate: () => findings,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function finding(patch: Partial<ReadinessFinding> = {}): ReadinessFinding {
|
||||
return {
|
||||
subjectId: 'global',
|
||||
detail: 'Etwas fehlt.',
|
||||
fingerprintParts: { v: 1 },
|
||||
subjects: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function subjects(count: number): ReadinessSubject[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
type: 'APIARY',
|
||||
id: `a${index}`,
|
||||
label: `Stand ${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function ack(patch: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'ack1',
|
||||
itemKey: 'fake',
|
||||
subjectId: 'global',
|
||||
fingerprint: fingerprintOf({
|
||||
key: 'fake',
|
||||
subjectId: 'global',
|
||||
parts: { v: 1 },
|
||||
}),
|
||||
note: 'Geprüft und in Ordnung.',
|
||||
createdAt: new Date('2027-03-01T08:00:00.000Z'),
|
||||
revokedAt: null,
|
||||
user: { name: 'Imker Eins' },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReadinessService', () => {
|
||||
let service: ReadinessService;
|
||||
let findAcknowledgements: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
rules.splice(0, rules.length);
|
||||
loadContextMock.mockReset().mockResolvedValue(readinessContext());
|
||||
findAcknowledgements = jest.fn().mockResolvedValue([]);
|
||||
const prisma = {
|
||||
complianceAcknowledgement: { findMany: findAcknowledgements },
|
||||
} as unknown as PrismaService;
|
||||
service = new ReadinessService(prisma, rules);
|
||||
});
|
||||
|
||||
it('is READY when no rule has an open finding', async () => {
|
||||
rules.push(fakeRule({ key: 'a' }), fakeRule({ key: 'b' }));
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.status).toBe('READY');
|
||||
expect(report.openCount).toBe(0);
|
||||
expect(report.generatedAt).toBe(NOW.toISOString());
|
||||
});
|
||||
|
||||
it('a rule without findings yields one DONE item with subjectId global', async () => {
|
||||
rules.push(fakeRule({ key: 'a', title: 'Alles da' }));
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'a',
|
||||
subjectId: 'global',
|
||||
title: 'Alles da',
|
||||
state: 'DONE',
|
||||
subjects: [],
|
||||
more: 0,
|
||||
acknowledgement: null,
|
||||
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/) as unknown,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('overall status is OPEN as long as one item is OPEN, an ACKNOWLEDGED item does not count', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
key: 'fake',
|
||||
severity: 'PRUEFEN',
|
||||
acknowledgeable: true,
|
||||
findings: [finding()],
|
||||
}),
|
||||
);
|
||||
findAcknowledgements.mockResolvedValue([ack()]);
|
||||
|
||||
const acknowledged = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(acknowledged.status).toBe('READY');
|
||||
expect(acknowledged.openCount).toBe(0);
|
||||
expect(acknowledged.items[0]).toMatchObject({
|
||||
state: 'ACKNOWLEDGED',
|
||||
acknowledgement: {
|
||||
id: 'ack1',
|
||||
note: 'Geprüft und in Ordnung.',
|
||||
userName: 'Imker Eins',
|
||||
createdAt: '2027-03-01T08:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
rules.push(fakeRule({ key: 'other', findings: [finding()] }));
|
||||
const withOpen = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(withOpen.status).toBe('OPEN');
|
||||
expect(withOpen.openCount).toBe(1);
|
||||
});
|
||||
|
||||
it('limits subjects to 50 and reports the rest in more', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
findings: [finding({ subjects: subjects(53), more: 4 })],
|
||||
}),
|
||||
);
|
||||
|
||||
const [item] = (await service.evaluate(NOW, new Set())).items;
|
||||
|
||||
expect(item.subjects).toHaveLength(50);
|
||||
expect(item.more).toBe(7);
|
||||
});
|
||||
|
||||
it('hides subject labels without the rule subjectPermission and keeps the count', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
subjectPermission: 'apiaries:read',
|
||||
findings: [finding({ subjects: subjects(3) })],
|
||||
}),
|
||||
);
|
||||
|
||||
const hidden = (await service.evaluate(NOW, new Set())).items[0];
|
||||
const shown = (await service.evaluate(NOW, new Set(['apiaries:read'])))
|
||||
.items[0];
|
||||
|
||||
expect(hidden.subjects).toEqual([]);
|
||||
expect(hidden.more).toBe(3);
|
||||
expect(JSON.stringify(hidden)).not.toContain('Stand 0');
|
||||
expect(shown.subjects).toHaveLength(3);
|
||||
expect(shown.more).toBe(0);
|
||||
});
|
||||
|
||||
it('summary names the first open PFLICHT item, else the first open item', async () => {
|
||||
rules.push(
|
||||
fakeRule({ key: 'done' }),
|
||||
fakeRule({ key: 'check', severity: 'PRUEFEN', findings: [finding()] }),
|
||||
fakeRule({ key: 'must', severity: 'PFLICHT', findings: [finding()] }),
|
||||
);
|
||||
|
||||
expect(await service.summary(NOW)).toEqual({
|
||||
status: 'OPEN',
|
||||
openCount: 2,
|
||||
firstItemKey: 'must',
|
||||
});
|
||||
|
||||
rules.splice(2, 1);
|
||||
|
||||
expect(await service.summary(NOW)).toEqual({
|
||||
status: 'OPEN',
|
||||
openCount: 1,
|
||||
firstItemKey: 'check',
|
||||
});
|
||||
|
||||
rules.splice(1, 1);
|
||||
|
||||
expect(await service.summary(NOW)).toEqual({
|
||||
status: 'READY',
|
||||
openCount: 0,
|
||||
firstItemKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('a revoked acknowledgement reopens the item', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
key: 'fake',
|
||||
severity: 'PRUEFEN',
|
||||
acknowledgeable: true,
|
||||
findings: [finding()],
|
||||
}),
|
||||
);
|
||||
findAcknowledgements.mockResolvedValue([
|
||||
ack({ revokedAt: new Date('2027-03-10T00:00:00.000Z') }),
|
||||
]);
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items[0].state).toBe('OPEN');
|
||||
expect(report.items[0].acknowledgement).toBeNull();
|
||||
});
|
||||
|
||||
it('a new fingerprint after changed data reopens an acknowledged item', async () => {
|
||||
const stale = ack();
|
||||
rules.push(
|
||||
fakeRule({
|
||||
key: 'fake',
|
||||
severity: 'PRUEFEN',
|
||||
acknowledgeable: true,
|
||||
findings: [finding({ fingerprintParts: { v: 2 } })],
|
||||
}),
|
||||
);
|
||||
findAcknowledgements.mockResolvedValue([stale]);
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items[0].state).toBe('OPEN');
|
||||
expect(report.items[0].fingerprint).not.toBe(stale.fingerprint);
|
||||
});
|
||||
|
||||
it('the newest valid acknowledgement wins', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
key: 'fake',
|
||||
severity: 'PRUEFEN',
|
||||
acknowledgeable: true,
|
||||
findings: [finding()],
|
||||
}),
|
||||
);
|
||||
findAcknowledgements.mockResolvedValue([
|
||||
ack({ id: 'newer', createdAt: new Date('2027-03-05T00:00:00.000Z') }),
|
||||
ack({ id: 'older', createdAt: new Date('2027-03-01T00:00:00.000Z') }),
|
||||
]);
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items[0].acknowledgement?.id).toBe('newer');
|
||||
});
|
||||
|
||||
it('never applies an acknowledgement to a rule that cannot be acknowledged', async () => {
|
||||
rules.push(
|
||||
fakeRule({ key: 'fake', acknowledgeable: false, findings: [finding()] }),
|
||||
);
|
||||
findAcknowledgements.mockResolvedValue([ack()]);
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items[0].state).toBe('OPEN');
|
||||
expect(findAcknowledgements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the finding title over the rule title and one item per finding', async () => {
|
||||
rules.push(
|
||||
fakeRule({
|
||||
title: 'Regel',
|
||||
findings: [
|
||||
finding({ subjectId: '2026', title: 'Zählung 2026' }),
|
||||
finding({ subjectId: '2027' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const report = await service.evaluate(NOW, new Set());
|
||||
|
||||
expect(report.items.map((item) => [item.subjectId, item.title])).toEqual([
|
||||
['2026', 'Zählung 2026'],
|
||||
['2027', 'Regel'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { fingerprintOf } from './fingerprint';
|
||||
import { loadReadinessContext } from './readiness-context';
|
||||
import {
|
||||
GLOBAL_SUBJECT_ID,
|
||||
MAX_SUBJECTS,
|
||||
type ReadinessAcknowledgement,
|
||||
type ReadinessFinding,
|
||||
type ReadinessItem,
|
||||
type ReadinessReport,
|
||||
type ReadinessRule,
|
||||
type ReadinessSubject,
|
||||
type ReadinessSummary,
|
||||
} from './readiness.types';
|
||||
import { READINESS_RULES_TOKEN } from './rules';
|
||||
|
||||
const DONE_DETAIL = 'Nichts offen.';
|
||||
|
||||
interface AcknowledgementRow {
|
||||
id: string;
|
||||
itemKey: string;
|
||||
subjectId: string;
|
||||
fingerprint: string;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
revokedAt: Date | null;
|
||||
user: { name: string };
|
||||
}
|
||||
|
||||
function itemId(key: string, subjectId: string): string {
|
||||
return `${key}\u0000${subjectId}`;
|
||||
}
|
||||
|
||||
/** At most MAX_SUBJECTS subjects; without the right to see them, none. */
|
||||
function visibleSubjects(
|
||||
rule: ReadinessRule,
|
||||
finding: ReadinessFinding,
|
||||
permissions: ReadonlySet<string>,
|
||||
): { subjects: ReadinessSubject[]; more: number } {
|
||||
const hidden =
|
||||
rule.subjectPermission && !permissions.has(rule.subjectPermission);
|
||||
if (hidden) {
|
||||
return {
|
||||
subjects: [],
|
||||
more: finding.subjects.length + (finding.more ?? 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
subjects: finding.subjects.slice(0, MAX_SUBJECTS),
|
||||
more:
|
||||
Math.max(0, finding.subjects.length - MAX_SUBJECTS) + (finding.more ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function openItem(
|
||||
rule: ReadinessRule,
|
||||
finding: ReadinessFinding,
|
||||
permissions: ReadonlySet<string>,
|
||||
): ReadinessItem {
|
||||
return {
|
||||
key: rule.key,
|
||||
area: rule.area,
|
||||
severity: rule.severity,
|
||||
subjectId: finding.subjectId,
|
||||
title: finding.title ?? rule.title,
|
||||
detail: finding.detail,
|
||||
legalBasis: rule.legalBasis,
|
||||
acknowledgeable: rule.acknowledgeable,
|
||||
fingerprint: fingerprintOf({
|
||||
key: rule.key,
|
||||
subjectId: finding.subjectId,
|
||||
parts: finding.fingerprintParts,
|
||||
}),
|
||||
...visibleSubjects(rule, finding, permissions),
|
||||
state: 'OPEN',
|
||||
acknowledgement: null,
|
||||
};
|
||||
}
|
||||
|
||||
function doneItem(rule: ReadinessRule): ReadinessItem {
|
||||
return {
|
||||
key: rule.key,
|
||||
area: rule.area,
|
||||
severity: rule.severity,
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
title: rule.title,
|
||||
detail: DONE_DETAIL,
|
||||
legalBasis: rule.legalBasis,
|
||||
acknowledgeable: rule.acknowledgeable,
|
||||
fingerprint: fingerprintOf({
|
||||
key: rule.key,
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
parts: null,
|
||||
}),
|
||||
subjects: [],
|
||||
more: 0,
|
||||
state: 'DONE',
|
||||
acknowledgement: null,
|
||||
};
|
||||
}
|
||||
|
||||
function toAcknowledgement(row: AcknowledgementRow): ReadinessAcknowledgement {
|
||||
return {
|
||||
id: row.id,
|
||||
note: row.note,
|
||||
userName: row.user.name,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isOpen(item: ReadinessItem): boolean {
|
||||
return item.state === 'OPEN';
|
||||
}
|
||||
|
||||
/**
|
||||
* The readiness is computed live from the data on every call; only the
|
||||
* acknowledgements are stored. An acknowledgement counts while it is not
|
||||
* revoked and its fingerprint still equals the current finding - changed
|
||||
* data or a revocation reopens the item.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReadinessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(READINESS_RULES_TOKEN)
|
||||
private readonly rules: readonly ReadinessRule[],
|
||||
) {}
|
||||
|
||||
async evaluate(
|
||||
now: Date,
|
||||
permissions: ReadonlySet<string>,
|
||||
): Promise<ReadinessReport> {
|
||||
const ctx = await loadReadinessContext(this.prisma, now);
|
||||
const drafts = this.rules.flatMap((rule) => {
|
||||
const findings = rule.evaluate(ctx);
|
||||
return findings.length === 0
|
||||
? [doneItem(rule)]
|
||||
: findings.map((finding) => openItem(rule, finding, permissions));
|
||||
});
|
||||
const acknowledged = await this.acknowledgementsFor(drafts);
|
||||
const items = drafts.map((item): ReadinessItem => {
|
||||
const found = acknowledged.get(itemId(item.key, item.subjectId));
|
||||
return found && isOpen(item)
|
||||
? { ...item, state: 'ACKNOWLEDGED', acknowledgement: found }
|
||||
: item;
|
||||
});
|
||||
const openCount = items.filter(isOpen).length;
|
||||
return {
|
||||
status: openCount === 0 ? 'READY' : 'OPEN',
|
||||
generatedAt: now.toISOString(),
|
||||
openCount,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
/** No names in it: the subjects stay hidden. */
|
||||
async summary(now: Date): Promise<ReadinessSummary> {
|
||||
const report = await this.evaluate(now, new Set());
|
||||
const open = report.items.filter(isOpen);
|
||||
const first = open.find((item) => item.severity === 'PFLICHT') ?? open[0];
|
||||
return {
|
||||
status: report.status,
|
||||
openCount: report.openCount,
|
||||
firstItemKey: first?.key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// The valid acknowledgement of each open item that can be acknowledged:
|
||||
// same item, same subject, same fingerprint, not revoked; the newest wins.
|
||||
private async acknowledgementsFor(
|
||||
items: readonly ReadinessItem[],
|
||||
): Promise<Map<string, ReadinessAcknowledgement>> {
|
||||
const candidates = items.filter(
|
||||
(item) => item.acknowledgeable && isOpen(item),
|
||||
);
|
||||
const result = new Map<string, ReadinessAcknowledgement>();
|
||||
if (candidates.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const rows: AcknowledgementRow[] =
|
||||
await this.prisma.complianceAcknowledgement.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
OR: candidates.map((item) => ({
|
||||
itemKey: item.key,
|
||||
subjectId: item.subjectId,
|
||||
fingerprint: item.fingerprint,
|
||||
})),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
itemKey: true,
|
||||
subjectId: true,
|
||||
fingerprint: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const fingerprints = new Map(
|
||||
candidates.map((item) => [
|
||||
itemId(item.key, item.subjectId),
|
||||
item.fingerprint,
|
||||
]),
|
||||
);
|
||||
const newestFirst = [...rows].sort(
|
||||
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
);
|
||||
for (const row of newestFirst) {
|
||||
const id = itemId(row.itemKey, row.subjectId);
|
||||
// The query already filters; the same test here keeps a revoked or
|
||||
// outdated row out whatever the query returns.
|
||||
if (
|
||||
row.revokedAt === null &&
|
||||
fingerprints.get(id) === row.fingerprint &&
|
||||
!result.has(id)
|
||||
) {
|
||||
result.set(id, toAcknowledgement(row));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PermissionKey } from '../../auth/permissions/permission-catalog';
|
||||
import type { ReadinessContext } from './readiness-context';
|
||||
|
||||
export type ReadinessSeverity = 'PFLICHT' | 'PRUEFEN';
|
||||
export type ReadinessArea =
|
||||
'STAMMDATEN' | 'BEHOERDEN' | 'TIERARZNEIMITTEL' | 'ETIKETTEN' | 'ERNTE';
|
||||
|
||||
export type ReadinessItemState = 'OPEN' | 'DONE' | 'ACKNOWLEDGED';
|
||||
|
||||
/** subjectId of a rule that has one single item (no subject of its own). */
|
||||
export const GLOBAL_SUBJECT_ID = 'global';
|
||||
/** The most subjects one item lists; the rest is only counted in `more`. */
|
||||
export const MAX_SUBJECTS = 50;
|
||||
|
||||
export interface ReadinessSubject {
|
||||
type: string;
|
||||
id: string;
|
||||
label: string;
|
||||
missing?: string[];
|
||||
/** e.g. the colony of a treatment (link target). */
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface ReadinessFinding {
|
||||
subjectId: string;
|
||||
/** Overrides the rule title (e.g. the year in it). */
|
||||
title?: string;
|
||||
/** One short German sentence, never with a name or an address. */
|
||||
detail: string;
|
||||
/** Everything the finding rests on: a change in it reopens an acknowledgement. */
|
||||
fingerprintParts: unknown;
|
||||
subjects: ReadinessSubject[];
|
||||
more?: number;
|
||||
}
|
||||
|
||||
export interface ReadinessRule {
|
||||
readonly key: string;
|
||||
readonly area: ReadinessArea;
|
||||
readonly severity: ReadinessSeverity;
|
||||
readonly title: string;
|
||||
readonly legalBasis: string | null;
|
||||
readonly acknowledgeable: boolean;
|
||||
/** Without it the caller sees how many subjects there are, not which. */
|
||||
readonly subjectPermission?: PermissionKey;
|
||||
evaluate(ctx: ReadinessContext): ReadinessFinding[];
|
||||
}
|
||||
|
||||
export interface ReadinessAcknowledgement {
|
||||
id: string;
|
||||
note: string;
|
||||
userName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReadinessItem {
|
||||
key: string;
|
||||
area: ReadinessArea;
|
||||
severity: ReadinessSeverity;
|
||||
subjectId: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
legalBasis: string | null;
|
||||
acknowledgeable: boolean;
|
||||
fingerprint: string;
|
||||
subjects: ReadinessSubject[];
|
||||
more: number;
|
||||
state: ReadinessItemState;
|
||||
acknowledgement: ReadinessAcknowledgement | null;
|
||||
}
|
||||
|
||||
export interface ReadinessReport {
|
||||
status: 'READY' | 'OPEN';
|
||||
generatedAt: string;
|
||||
openCount: number;
|
||||
items: ReadinessItem[];
|
||||
}
|
||||
|
||||
export interface ReadinessSummary {
|
||||
status: 'READY' | 'OPEN';
|
||||
openCount: number;
|
||||
firstItemKey: string | null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { foodBusinessDataRule } from './food-business-data.rule';
|
||||
import { readinessContext, siteSettings } from './rule-spec-helpers';
|
||||
|
||||
describe('food-business-data rule', () => {
|
||||
it('is open when the name is empty', () => {
|
||||
const findings = foodBusinessDataRule.evaluate(
|
||||
readinessContext({ site: siteSettings({ foodBusinessName: null }) }),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjects[0].missing).toEqual(['Name']);
|
||||
});
|
||||
|
||||
it('is open when the address is empty', () => {
|
||||
const findings = foodBusinessDataRule.evaluate(
|
||||
readinessContext({
|
||||
site: siteSettings({ foodBusinessAddress: ' ' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings[0].subjects[0].missing).toEqual(['Anschrift']);
|
||||
});
|
||||
|
||||
it('is done when both are set', () => {
|
||||
expect(foodBusinessDataRule.evaluate(readinessContext())).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a Pflicht rule without acknowledgement', () => {
|
||||
expect(foodBusinessDataRule).toMatchObject({
|
||||
key: 'food-business-data',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
import { isBlank, siteSettingsSubject } from './rule-helpers';
|
||||
|
||||
export const foodBusinessDataRule: ReadinessRule = {
|
||||
key: 'food-business-data',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Name und Anschrift des Lebensmittelunternehmers eintragen.',
|
||||
legalBasis: 'LMIV Art. 8',
|
||||
acknowledgeable: false,
|
||||
evaluate: ({ site }): ReadinessFinding[] => {
|
||||
const missing = [
|
||||
...(isBlank(site.foodBusinessName) ? ['Name'] : []),
|
||||
...(isBlank(site.foodBusinessAddress) ? ['Anschrift'] : []),
|
||||
];
|
||||
if (missing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail: `Beim Lebensmittelunternehmer fehlt: ${missing.join(' und ')}.`,
|
||||
fingerprintParts: { missing },
|
||||
subjects: [siteSettingsSubject(missing)],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { HerdDeviation } from '../../herd-snapshot';
|
||||
import { fingerprintOf } from '../fingerprint';
|
||||
import { herdChangeRule } from './herd-change.rule';
|
||||
import {
|
||||
emptySnapshot,
|
||||
herdStatus,
|
||||
notice,
|
||||
readinessContext,
|
||||
} from './rule-spec-helpers';
|
||||
|
||||
function deviation(patch: Partial<HerdDeviation> = {}): HerdDeviation {
|
||||
return {
|
||||
triggered: true,
|
||||
colonyCountChanged: true,
|
||||
apiariesAdded: [],
|
||||
apiariesRemoved: [],
|
||||
apiariesRelocated: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function apiary(id: string, name: string) {
|
||||
return {
|
||||
apiaryId: id,
|
||||
name,
|
||||
address: null,
|
||||
location: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
livingColonies: 1,
|
||||
byType: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('herd-change rule', () => {
|
||||
it('is open when the deviation is triggered', () => {
|
||||
const findings = herdChangeRule.evaluate(
|
||||
readinessContext({
|
||||
herd: herdStatus({
|
||||
baseline: notice({ totalColonies: 3 }) as never,
|
||||
current: { ...emptySnapshot(), totalLiving: 5 },
|
||||
deviation: deviation(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjectId).toBe('global');
|
||||
expect(findings[0].detail).toContain('5');
|
||||
expect(findings[0].detail).toContain('3');
|
||||
});
|
||||
|
||||
it('is done without a baseline: nothing to deviate from', () => {
|
||||
expect(
|
||||
herdChangeRule.evaluate(
|
||||
readinessContext({ herd: herdStatus({ deviation: null }) }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('is done when the deviation is not triggered', () => {
|
||||
expect(
|
||||
herdChangeRule.evaluate(
|
||||
readinessContext({
|
||||
herd: herdStatus({
|
||||
baseline: notice() as never,
|
||||
deviation: deviation({ triggered: false }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists the changed apiaries as subjects, removed ones from the baseline snapshot', () => {
|
||||
const findings = herdChangeRule.evaluate(
|
||||
readinessContext({
|
||||
herd: herdStatus({
|
||||
baseline: {
|
||||
...notice(),
|
||||
snapshot: { apiaries: [apiary('gone', 'Alter Stand')] },
|
||||
},
|
||||
current: {
|
||||
...emptySnapshot(),
|
||||
apiaries: [apiary('new', 'Neuer Stand')],
|
||||
},
|
||||
deviation: deviation({
|
||||
apiariesAdded: ['new'],
|
||||
apiariesRemoved: ['gone'],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings[0].subjects.map((subject) => subject.label)).toEqual([
|
||||
'Neuer Stand',
|
||||
'Alter Stand',
|
||||
]);
|
||||
expect(herdChangeRule.subjectPermission).toBe('apiaries:read');
|
||||
expect(findings[0].detail).not.toContain('Stand');
|
||||
});
|
||||
|
||||
it('gives an equal fingerprint for the same state with permuted lists', () => {
|
||||
const run = (added: string[], removed: string[], relocated: string[]) =>
|
||||
herdChangeRule.evaluate(
|
||||
readinessContext({
|
||||
herd: herdStatus({
|
||||
baseline: notice() as never,
|
||||
deviation: deviation({
|
||||
apiariesAdded: added,
|
||||
apiariesRemoved: removed,
|
||||
apiariesRelocated: relocated,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)[0].fingerprintParts;
|
||||
|
||||
expect(fingerprintOf(run(['a', 'b'], ['c', 'd'], ['e', 'f']))).toBe(
|
||||
fingerprintOf(run(['b', 'a'], ['d', 'c'], ['f', 'e'])),
|
||||
);
|
||||
});
|
||||
|
||||
it('is a Pflicht rule that cannot be acknowledged', () => {
|
||||
expect(herdChangeRule).toMatchObject({
|
||||
key: 'herd-change',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { HerdSnapshot } from '../../herd-snapshot';
|
||||
import type { ReadinessContext } from '../readiness-context';
|
||||
import type {
|
||||
ReadinessFinding,
|
||||
ReadinessRule,
|
||||
ReadinessSubject,
|
||||
} from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
|
||||
function snapshotOf(value: unknown): HerdSnapshot | null {
|
||||
return value && typeof value === 'object' && 'apiaries' in value
|
||||
? (value as HerdSnapshot)
|
||||
: null;
|
||||
}
|
||||
|
||||
function nameOf(
|
||||
id: string,
|
||||
...snapshots: (HerdSnapshot | null)[]
|
||||
): string | null {
|
||||
for (const snapshot of snapshots) {
|
||||
const found = snapshot?.apiaries?.find((entry) => entry.apiaryId === id);
|
||||
if (found) {
|
||||
return found.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function subjectsOf({ herd }: ReadinessContext): ReadinessSubject[] {
|
||||
const { deviation, baseline, current } = herd;
|
||||
if (!deviation) {
|
||||
return [];
|
||||
}
|
||||
const baselineSnapshot = snapshotOf(baseline?.snapshot);
|
||||
const subjects: ReadinessSubject[] = [];
|
||||
const add = (ids: string[], reason: string) => {
|
||||
for (const id of ids) {
|
||||
subjects.push({
|
||||
type: 'APIARY',
|
||||
id,
|
||||
// Removed apiaries are gone from the current picture.
|
||||
label: nameOf(id, current, baselineSnapshot) ?? id,
|
||||
missing: [reason],
|
||||
});
|
||||
}
|
||||
};
|
||||
add(deviation.apiariesAdded, 'neu');
|
||||
add(deviation.apiariesRemoved, 'aufgegeben');
|
||||
add(deviation.apiariesRelocated, 'verlegt');
|
||||
return subjects;
|
||||
}
|
||||
|
||||
function sorted(ids: readonly string[]): string[] {
|
||||
return [...ids].sort();
|
||||
}
|
||||
|
||||
export const herdChangeRule: ReadinessRule = {
|
||||
key: 'herd-change',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Die Änderung des Bestands beim Veterinäramt anzeigen.',
|
||||
legalBasis: 'BienSeuchV § 1a (Umfang: Annahme)',
|
||||
acknowledgeable: false,
|
||||
subjectPermission: 'apiaries:read',
|
||||
evaluate: (ctx): ReadinessFinding[] => {
|
||||
const { baseline, current, deviation } = ctx.herd;
|
||||
if (!baseline || !deviation?.triggered) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail: `Der Bestand weicht von der letzten Anzeige ab: jetzt ${current.totalLiving} Völker, angezeigt ${baseline.totalColonies}.`,
|
||||
fingerprintParts: {
|
||||
baselineId: baseline.id,
|
||||
totalLiving: current.totalLiving,
|
||||
totalColonies: baseline.totalColonies,
|
||||
colonyCountChanged: deviation.colonyCountChanged,
|
||||
apiariesAdded: sorted(deviation.apiariesAdded),
|
||||
apiariesRemoved: sorted(deviation.apiariesRemoved),
|
||||
apiariesRelocated: sorted(deviation.apiariesRelocated),
|
||||
},
|
||||
subjects: subjectsOf(ctx),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { holderDataRule } from './holder-data.rule';
|
||||
import { readinessContext, siteSettings } from './rule-spec-helpers';
|
||||
|
||||
describe('holder-data rule', () => {
|
||||
it('is open when the holder name is empty', () => {
|
||||
const findings = holderDataRule.evaluate(
|
||||
readinessContext({ site: siteSettings({ holderName: ' ' }) }),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjectId).toBe('global');
|
||||
expect(findings[0].subjects[0].missing).toEqual(['Name']);
|
||||
});
|
||||
|
||||
it('is open when the address is empty and lists both gaps when both are empty', () => {
|
||||
const addressOnly = holderDataRule.evaluate(
|
||||
readinessContext({ site: siteSettings({ holderAddress: null }) }),
|
||||
);
|
||||
const both = holderDataRule.evaluate(
|
||||
readinessContext({
|
||||
site: siteSettings({ holderName: null, holderAddress: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(addressOnly[0].subjects[0].missing).toEqual(['Anschrift']);
|
||||
expect(both[0].subjects[0].missing).toEqual(['Name', 'Anschrift']);
|
||||
});
|
||||
|
||||
it('is done when name and address are set', () => {
|
||||
expect(holderDataRule.evaluate(readinessContext())).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a Pflicht rule that cannot be acknowledged', () => {
|
||||
expect(holderDataRule).toMatchObject({
|
||||
key: 'holder-data',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('never puts the stored name or address into a finding', () => {
|
||||
const findings = holderDataRule.evaluate(
|
||||
readinessContext({
|
||||
site: siteSettings({ holderName: null, holderAddress: 'Geheimweg 7' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(JSON.stringify(findings)).not.toContain('Geheimweg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
import { isBlank, siteSettingsSubject } from './rule-helpers';
|
||||
|
||||
export const holderDataRule: ReadinessRule = {
|
||||
key: 'holder-data',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Name und Anschrift des Bienenhalters eintragen.',
|
||||
legalBasis: 'Art. 108 VO (EU) 2019/6',
|
||||
acknowledgeable: false,
|
||||
evaluate: ({ site }): ReadinessFinding[] => {
|
||||
const missing = [
|
||||
...(isBlank(site.holderName) ? ['Name'] : []),
|
||||
...(isBlank(site.holderAddress) ? ['Anschrift'] : []),
|
||||
];
|
||||
if (missing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail: `Beim Bienenhalter fehlt: ${missing.join(' und ')}.`,
|
||||
fingerprintParts: { missing },
|
||||
subjects: [siteSettingsSubject(missing)],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { READINESS_RULES } from './index';
|
||||
import {
|
||||
complianceSettings,
|
||||
herdStatus,
|
||||
notice,
|
||||
readinessContext,
|
||||
restriction,
|
||||
siteSettings,
|
||||
} from './rule-spec-helpers';
|
||||
|
||||
describe('READINESS_RULES', () => {
|
||||
it('has unique keys', () => {
|
||||
const keys = READINESS_RULES.map((rule) => rule.key);
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('yields unique subjectIds per rule for a context in which rules are open', () => {
|
||||
const ctx = readinessContext({
|
||||
site: siteSettings({ holderName: null, foodBusinessName: null }),
|
||||
settings: complianceSettings({ veterinaryOfficeName: null }),
|
||||
herd: herdStatus(),
|
||||
notices: [
|
||||
notice({ id: 'old', kind: 'TSK_REPORT', year: 2026, status: 'DRAFT' }),
|
||||
],
|
||||
restrictions: [
|
||||
restriction({ id: 'r1' }),
|
||||
restriction({ id: 'r2', apiary: { id: 'a2', name: 'Zwei' } }),
|
||||
],
|
||||
});
|
||||
|
||||
for (const rule of READINESS_RULES) {
|
||||
const ids = rule.evaluate(ctx).map((finding) => finding.subjectId);
|
||||
|
||||
expect({
|
||||
key: rule.key,
|
||||
unique: new Set(ids).size === ids.length,
|
||||
}).toEqual({ key: rule.key, unique: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReadinessRule } from '../readiness.types';
|
||||
import { foodBusinessDataRule } from './food-business-data.rule';
|
||||
import { herdChangeRule } from './herd-change.rule';
|
||||
import { holderDataRule } from './holder-data.rule';
|
||||
import { registrationRule } from './registration.rule';
|
||||
import { restrictionActiveRule } from './restriction-active.rule';
|
||||
import { tskReportRule } from './tsk-report.rule';
|
||||
import { vetOfficeRule } from './vet-office.rule';
|
||||
|
||||
/** In the order of the report; Task 13 appends the other rules. */
|
||||
export const READINESS_RULES: readonly ReadinessRule[] = [
|
||||
holderDataRule,
|
||||
foodBusinessDataRule,
|
||||
vetOfficeRule,
|
||||
registrationRule,
|
||||
herdChangeRule,
|
||||
tskReportRule,
|
||||
restrictionActiveRule,
|
||||
];
|
||||
|
||||
/** Injection token: the module provides the list, tests may override it. */
|
||||
export const READINESS_RULES_TOKEN = Symbol('READINESS_RULES');
|
||||
@@ -0,0 +1,53 @@
|
||||
import { registrationRule } from './registration.rule';
|
||||
import { notice, readinessContext } from './rule-spec-helpers';
|
||||
|
||||
describe('registration rule', () => {
|
||||
it('is open without any registration', () => {
|
||||
const findings = registrationRule.evaluate(readinessContext());
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjectId).toBe('global');
|
||||
expect(findings[0].subjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('is open while the registration is only a draft and points to the draft', () => {
|
||||
const findings = registrationRule.evaluate(
|
||||
readinessContext({
|
||||
notices: [notice({ id: 'd1', status: 'DRAFT', sentOn: null })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjects).toEqual([
|
||||
expect.objectContaining({ type: 'AUTHORITY_NOTICE', id: 'd1' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a herd change and a tsk report', () => {
|
||||
const findings = registrationRule.evaluate(
|
||||
readinessContext({
|
||||
notices: [
|
||||
notice({ kind: 'HERD_CHANGE' }),
|
||||
notice({ kind: 'TSK_REPORT', year: 2027 }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is done with a SENT registration', () => {
|
||||
expect(
|
||||
registrationRule.evaluate(readinessContext({ notices: [notice()] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a Pflicht rule in the Behörden area', () => {
|
||||
expect(registrationRule).toMatchObject({
|
||||
key: 'registration',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
|
||||
export const registrationRule: ReadinessRule = {
|
||||
key: 'registration',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Die Erstanzeige beim Veterinäramt erfassen.',
|
||||
legalBasis: 'BienSeuchV § 1a',
|
||||
acknowledgeable: false,
|
||||
evaluate: ({ notices }): ReadinessFinding[] => {
|
||||
const registrations = notices.filter(
|
||||
(notice) => notice.kind === 'REGISTRATION',
|
||||
);
|
||||
if (registrations.some((notice) => notice.status === 'SENT')) {
|
||||
return [];
|
||||
}
|
||||
const draft = registrations.find((notice) => notice.status === 'DRAFT');
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail: draft
|
||||
? 'Die Erstanzeige ist noch ein Entwurf und nicht als gesendet vermerkt.'
|
||||
: 'Es gibt noch keine gesendete Erstanzeige.',
|
||||
fingerprintParts: { draftId: draft?.id ?? null },
|
||||
subjects: draft
|
||||
? [
|
||||
{
|
||||
type: 'AUTHORITY_NOTICE',
|
||||
id: draft.id,
|
||||
label: 'Erstanzeige (Entwurf)',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { restrictionActiveRule } from './restriction-active.rule';
|
||||
import { readinessContext, restriction } from './rule-spec-helpers';
|
||||
|
||||
describe('restriction-active rule', () => {
|
||||
it('is open with one finding listing each apiary with an active restriction once', () => {
|
||||
const findings = restrictionActiveRule.evaluate(
|
||||
readinessContext({
|
||||
restrictions: [
|
||||
restriction({ id: 'r1', apiary: { id: 'a1', name: 'Stand Eins' } }),
|
||||
restriction({ id: 'r2', apiary: { id: 'a1', name: 'Stand Eins' } }),
|
||||
restriction({ id: 'r3', apiary: { id: 'a2', name: 'Stand Zwei' } }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjectId).toBe('global');
|
||||
expect(findings[0].subjects.map((subject) => subject.label)).toEqual([
|
||||
'Stand Eins',
|
||||
'Stand Zwei',
|
||||
]);
|
||||
expect(findings[0].subjects[0]).toMatchObject({
|
||||
type: 'APIARY',
|
||||
id: 'a1',
|
||||
});
|
||||
});
|
||||
|
||||
it('is done without a restriction and for one that has ended or not begun', () => {
|
||||
const ended = restriction({ endsOn: new Date('2027-03-14T00:00:00.000Z') });
|
||||
const future = restriction({
|
||||
startsOn: new Date('2027-03-16T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(restrictionActiveRule.evaluate(readinessContext())).toEqual([]);
|
||||
expect(
|
||||
restrictionActiveRule.evaluate(
|
||||
readinessContext({ restrictions: [ended, future] }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the first and the last day as active (Berlin day of the context)', () => {
|
||||
const lastDay = restriction({
|
||||
endsOn: new Date('2027-03-15T00:00:00.000Z'),
|
||||
});
|
||||
const firstDay = restriction({
|
||||
id: 'r2',
|
||||
startsOn: new Date('2027-03-15T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(
|
||||
restrictionActiveRule.evaluate(
|
||||
readinessContext({ restrictions: [lastDay] }),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
restrictionActiveRule.evaluate(
|
||||
readinessContext({ restrictions: [firstDay] }),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps names out of the texts, the fingerprint follows the restrictions', () => {
|
||||
const first = restrictionActiveRule.evaluate(
|
||||
readinessContext({ restrictions: [restriction({ id: 'r1' })] }),
|
||||
)[0];
|
||||
const second = restrictionActiveRule.evaluate(
|
||||
readinessContext({
|
||||
restrictions: [restriction({ id: 'r1' }), restriction({ id: 'r2' })],
|
||||
}),
|
||||
)[0];
|
||||
|
||||
expect(first.detail).not.toContain('Stand Eins');
|
||||
expect(first.fingerprintParts).not.toEqual(second.fingerprintParts);
|
||||
expect(restrictionActiveRule.subjectPermission).toBe('apiaries:read');
|
||||
});
|
||||
|
||||
it('is a Pflicht rule that cannot be acknowledged', () => {
|
||||
expect(restrictionActiveRule).toMatchObject({
|
||||
key: 'restriction-active',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { isRestrictionActive } from '../../restricted-zone';
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
|
||||
export const restrictionActiveRule: ReadinessRule = {
|
||||
key: 'restriction-active',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Die Verfügung zum Sperrbezirk prüfen.',
|
||||
legalBasis: 'BienSeuchV § 10',
|
||||
acknowledgeable: false,
|
||||
subjectPermission: 'apiaries:read',
|
||||
evaluate: ({ restrictions, today }): ReadinessFinding[] => {
|
||||
// The same Berlin day and the same test as the guard on the colonies.
|
||||
const active = restrictions.filter((restriction) =>
|
||||
isRestrictionActive(restriction, today),
|
||||
);
|
||||
if (active.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const apiaries = new Map<string, string>();
|
||||
for (const restriction of active) {
|
||||
apiaries.set(restriction.apiary.id, restriction.apiary.name);
|
||||
}
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail:
|
||||
apiaries.size === 1
|
||||
? 'Ein Stand liegt im Sperrbezirk. Ist die Sperre aufgehoben, das Ende eintragen.'
|
||||
: `${apiaries.size} Stände liegen im Sperrbezirk. Ist eine Sperre aufgehoben, das Ende eintragen.`,
|
||||
fingerprintParts: {
|
||||
restrictionIds: active.map((restriction) => restriction.id).sort(),
|
||||
},
|
||||
subjects: [...apiaries].map(([id, name]) => ({
|
||||
type: 'APIARY',
|
||||
id,
|
||||
label: name,
|
||||
})),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReadinessSubject } from '../readiness.types';
|
||||
|
||||
/** True for null, undefined and a text of blanks only. */
|
||||
export function isBlank(value: string | null | undefined): boolean {
|
||||
return value == null || value.trim() === '';
|
||||
}
|
||||
|
||||
/** The place to fix the master data: the settings row, with the gaps named. */
|
||||
export function siteSettingsSubject(missing: string[]): ReadinessSubject {
|
||||
return {
|
||||
type: 'SITE_SETTINGS',
|
||||
id: 'singleton',
|
||||
label: 'Stammdaten',
|
||||
missing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
ApiaryRestriction,
|
||||
ComplianceSettings,
|
||||
SiteSettings,
|
||||
} from '../../../../generated/prisma';
|
||||
import type { HerdSnapshot } from '../../herd-snapshot';
|
||||
import type { HerdStatus } from '../../herd-status';
|
||||
import type { ReadinessContext, ReadinessNotice } from '../readiness-context';
|
||||
|
||||
/** Builders for the pure rule specs (and for the rules of later tasks). */
|
||||
export function siteSettings(patch: Partial<SiteSettings> = {}): SiteSettings {
|
||||
return {
|
||||
id: 'singleton',
|
||||
youtubeUrl: null,
|
||||
instagramUrl: null,
|
||||
tiktokUrl: null,
|
||||
websiteUrl: null,
|
||||
holderName: 'Halter',
|
||||
holderAddress: 'Weg 1, 12345 Ort',
|
||||
foodBusinessName: 'Imkerei',
|
||||
foodBusinessAddress: 'Weg 2, 12345 Ort',
|
||||
originCountry: 'Deutschland',
|
||||
updatedAt: new Date(0),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function complianceSettings(
|
||||
patch: Partial<ComplianceSettings> = {},
|
||||
): ComplianceSettings {
|
||||
return {
|
||||
id: 'singleton',
|
||||
veterinaryOfficeName: 'Veterinäramt',
|
||||
veterinaryOfficeAddress: null,
|
||||
registrationNumber: null,
|
||||
noticeOnColonyCountChange: true,
|
||||
noticeOnApiaryChange: true,
|
||||
tskReferenceDay: '01-01',
|
||||
tskReportDueDay: null,
|
||||
tskMemberNumber: null,
|
||||
tskExcludedColonyTypes: [],
|
||||
netQuantityDigitRules: [],
|
||||
updatedAt: new Date(0),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptySnapshot(): HerdSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
takenAt: '2027-03-15T10:00:00.000Z',
|
||||
apiaries: [],
|
||||
totalLiving: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function notice(patch: Partial<ReadinessNotice> = {}): ReadinessNotice {
|
||||
return {
|
||||
id: 'n1',
|
||||
kind: 'REGISTRATION',
|
||||
status: 'SENT',
|
||||
year: null,
|
||||
referenceDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||
countedAt: new Date('2026-01-01T10:00:00.000Z'),
|
||||
totalColonies: 3,
|
||||
sentOn: new Date('2026-01-02T00:00:00.000Z'),
|
||||
sentVia: null,
|
||||
reference: null,
|
||||
notes: null,
|
||||
openKey: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function restriction(
|
||||
patch: Partial<ApiaryRestriction> & { apiary?: { id: string; name: string } },
|
||||
): ApiaryRestriction & { apiary: { id: string; name: string } } {
|
||||
const apiary = patch.apiary ?? { id: 'a1', name: 'Stand Eins' };
|
||||
return {
|
||||
id: 'r1',
|
||||
apiaryId: apiary.id,
|
||||
disease: 'Faulbrut',
|
||||
authority: null,
|
||||
reference: null,
|
||||
startsOn: new Date('2027-03-01T00:00:00.000Z'),
|
||||
endsOn: null,
|
||||
notes: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
...patch,
|
||||
apiary,
|
||||
};
|
||||
}
|
||||
|
||||
export function herdStatus(patch: Partial<HerdStatus> = {}): HerdStatus {
|
||||
return {
|
||||
settings: complianceSettings(),
|
||||
baseline: null,
|
||||
current: emptySnapshot(),
|
||||
deviation: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
export function readinessContext(
|
||||
patch: Partial<ReadinessContext> = {},
|
||||
): ReadinessContext {
|
||||
return {
|
||||
now: new Date('2027-03-15T10:00:00.000Z'),
|
||||
today: '2027-03-15',
|
||||
site: siteSettings(),
|
||||
settings: complianceSettings(),
|
||||
herd: herdStatus(),
|
||||
notices: [],
|
||||
restrictions: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
complianceSettings,
|
||||
notice,
|
||||
readinessContext,
|
||||
} from './rule-spec-helpers';
|
||||
import { tskReportRule } from './tsk-report.rule';
|
||||
|
||||
function at(today: string, patch: Parameters<typeof readinessContext>[0] = {}) {
|
||||
return readinessContext({ today, ...patch });
|
||||
}
|
||||
|
||||
describe('tsk-report rule', () => {
|
||||
it('is not open before the reference day of the year', () => {
|
||||
const findings = tskReportRule.evaluate(
|
||||
at('2027-03-15', {
|
||||
settings: complianceSettings({ tskReferenceDay: '06-01' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('is open from the reference day on without a SENT report, one finding per year', () => {
|
||||
const onTheDay = tskReportRule.evaluate(
|
||||
at('2027-06-01', {
|
||||
settings: complianceSettings({ tskReferenceDay: '06-01' }),
|
||||
}),
|
||||
);
|
||||
const afterwards = tskReportRule.evaluate(at('2027-03-15'));
|
||||
|
||||
expect(onTheDay.map((finding) => finding.subjectId)).toEqual(['2027']);
|
||||
expect(afterwards.map((finding) => finding.subjectId)).toEqual(['2027']);
|
||||
expect(afterwards[0].detail).not.toContain('überfällig');
|
||||
});
|
||||
|
||||
it('is done with a SENT report of that year', () => {
|
||||
const findings = tskReportRule.evaluate(
|
||||
at('2027-03-15', {
|
||||
notices: [notice({ kind: 'TSK_REPORT', year: 2027 })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays open for a DRAFT and points to it', () => {
|
||||
const findings = tskReportRule.evaluate(
|
||||
at('2027-03-15', {
|
||||
notices: [
|
||||
notice({
|
||||
id: 'd1',
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2027,
|
||||
status: 'DRAFT',
|
||||
sentOn: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings[0].subjects).toEqual([
|
||||
expect.objectContaining({ type: 'AUTHORITY_NOTICE', id: 'd1' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('says überfällig after the due day', () => {
|
||||
const settings = complianceSettings({ tskReportDueDay: '03-31' });
|
||||
|
||||
const onDueDay = tskReportRule.evaluate(at('2027-03-31', { settings }));
|
||||
const afterDueDay = tskReportRule.evaluate(at('2027-04-01', { settings }));
|
||||
|
||||
expect(onDueDay[0].detail).not.toContain('überfällig');
|
||||
expect(afterDueDay[0].detail).toContain('überfällig');
|
||||
expect(afterDueDay[0].fingerprintParts).not.toEqual(
|
||||
onDueDay[0].fingerprintParts,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the item of last year open while its DRAFT exists, and not otherwise', () => {
|
||||
const draftOfLastYear = notice({
|
||||
id: 'old',
|
||||
kind: 'TSK_REPORT',
|
||||
year: 2026,
|
||||
status: 'DRAFT',
|
||||
sentOn: null,
|
||||
});
|
||||
const sentThisYear = notice({ kind: 'TSK_REPORT', year: 2027, id: 'cur' });
|
||||
|
||||
const withDraft = tskReportRule.evaluate(
|
||||
at('2027-03-15', { notices: [draftOfLastYear, sentThisYear] }),
|
||||
);
|
||||
const withoutRecord = tskReportRule.evaluate(
|
||||
at('2027-03-15', { notices: [sentThisYear] }),
|
||||
);
|
||||
const sentLastYear = tskReportRule.evaluate(
|
||||
at('2027-03-15', {
|
||||
notices: [
|
||||
notice({ kind: 'TSK_REPORT', year: 2026, id: 'old' }),
|
||||
sentThisYear,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(withDraft.map((finding) => finding.subjectId)).toEqual(['2026']);
|
||||
expect(withoutRecord).toEqual([]);
|
||||
expect(sentLastYear).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a Pflicht rule that cannot be acknowledged', () => {
|
||||
expect(tskReportRule).toMatchObject({
|
||||
key: 'tsk-report',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { addDays, daysBetween } from '../../../common/dates';
|
||||
import { formatDayDe } from '../../../common/format-day';
|
||||
import { tskReferenceDayOf } from '../../tsk-day';
|
||||
import type { ReadinessContext } from '../readiness-context';
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
|
||||
/**
|
||||
* The due day of a report year: the first date with the configured MM-TT
|
||||
* on or after the reference day (a due day before the reference day falls
|
||||
* into the next year).
|
||||
*/
|
||||
function dueDayOf(
|
||||
year: number,
|
||||
referenceDay: string,
|
||||
dueMonthDay: string | null,
|
||||
): string | null {
|
||||
if (!dueMonthDay) {
|
||||
return null;
|
||||
}
|
||||
const sameYear = tskReferenceDayOf(year, dueMonthDay);
|
||||
return sameYear >= referenceDay
|
||||
? sameYear
|
||||
: tskReferenceDayOf(year + 1, dueMonthDay);
|
||||
}
|
||||
|
||||
function findingFor(
|
||||
ctx: ReadinessContext,
|
||||
year: number,
|
||||
needsDraft: boolean,
|
||||
): ReadinessFinding | null {
|
||||
const { today, settings, notices } = ctx;
|
||||
const reports = notices.filter(
|
||||
(notice) => notice.kind === 'TSK_REPORT' && notice.year === year,
|
||||
);
|
||||
if (reports.some((notice) => notice.status === 'SENT')) {
|
||||
return null;
|
||||
}
|
||||
const draft = reports.find((notice) => notice.status === 'DRAFT');
|
||||
// Last year counts only while its draft shows that the count was started.
|
||||
if (needsDraft && !draft) {
|
||||
return null;
|
||||
}
|
||||
const referenceDay = tskReferenceDayOf(year, settings.tskReferenceDay);
|
||||
if (daysBetween(referenceDay, today) < 0) {
|
||||
return null;
|
||||
}
|
||||
const dueDay = dueDayOf(year, referenceDay, settings.tskReportDueDay);
|
||||
const overdue = dueDay !== null && daysBetween(dueDay, today) > 0;
|
||||
const start = `Die TSK-Zählung ${year} ist noch nicht gemeldet`;
|
||||
return {
|
||||
subjectId: String(year),
|
||||
title: `Die TSK-Zählung ${year} melden.`,
|
||||
detail:
|
||||
overdue && dueDay
|
||||
? `${start} und überfällig seit ${formatDayDe(addDays(dueDay, 1), true)}.`
|
||||
: `${start}.`,
|
||||
fingerprintParts: { year, draftId: draft?.id ?? null, overdue },
|
||||
subjects: draft
|
||||
? [
|
||||
{
|
||||
type: 'AUTHORITY_NOTICE',
|
||||
id: draft.id,
|
||||
label: `TSK-Zählung ${year} (Entwurf)`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const tskReportRule: ReadinessRule = {
|
||||
key: 'tsk-report',
|
||||
area: 'BEHOERDEN',
|
||||
severity: 'PFLICHT',
|
||||
title: 'Die TSK-Zählung melden.',
|
||||
legalBasis: 'Landesrecht Bayern (Annahme)',
|
||||
acknowledgeable: false,
|
||||
evaluate: (ctx): ReadinessFinding[] => {
|
||||
const thisYear = Number(ctx.today.slice(0, 4));
|
||||
return [
|
||||
findingFor(ctx, thisYear - 1, true),
|
||||
findingFor(ctx, thisYear, false),
|
||||
].filter((finding): finding is ReadinessFinding => finding !== null);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { vetOfficeRule } from './vet-office.rule';
|
||||
import { complianceSettings, readinessContext } from './rule-spec-helpers';
|
||||
|
||||
describe('vet-office rule', () => {
|
||||
it('is open without an office name', () => {
|
||||
for (const veterinaryOfficeName of [null, '', ' ']) {
|
||||
const findings = vetOfficeRule.evaluate(
|
||||
readinessContext({
|
||||
settings: complianceSettings({ veterinaryOfficeName }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].subjectId).toBe('global');
|
||||
}
|
||||
});
|
||||
|
||||
it('is done with an office name', () => {
|
||||
expect(vetOfficeRule.evaluate(readinessContext())).toEqual([]);
|
||||
});
|
||||
|
||||
it('is a Prüfen rule without acknowledgement', () => {
|
||||
expect(vetOfficeRule).toMatchObject({
|
||||
key: 'vet-office',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PRUEFEN',
|
||||
legalBasis: null,
|
||||
acknowledgeable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReadinessFinding, ReadinessRule } from '../readiness.types';
|
||||
import { GLOBAL_SUBJECT_ID } from '../readiness.types';
|
||||
import { isBlank } from './rule-helpers';
|
||||
|
||||
export const vetOfficeRule: ReadinessRule = {
|
||||
key: 'vet-office',
|
||||
area: 'STAMMDATEN',
|
||||
severity: 'PRUEFEN',
|
||||
title: 'Das Veterinäramt als Empfänger eintragen.',
|
||||
legalBasis: null,
|
||||
acknowledgeable: false,
|
||||
evaluate: ({ settings }): ReadinessFinding[] => {
|
||||
if (!isBlank(settings.veterinaryOfficeName)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
subjectId: GLOBAL_SUBJECT_ID,
|
||||
detail: 'Der Name des Veterinäramts fehlt in den Einstellungen.',
|
||||
fingerprintParts: { veterinaryOfficeName: false },
|
||||
subjects: [
|
||||
{
|
||||
type: 'COMPLIANCE_SETTINGS',
|
||||
id: 'singleton',
|
||||
label: 'Einstellungen',
|
||||
missing: ['Name des Veterinäramts'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user