feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger (rest always computed; corrections as counter-bookings; inactive medicines take no new stock; unit must match on lot moves). - Documents of medicine lots, authority notices and restrictions need the domain right on top of documents:* (403 before any lookup). - Label particulars: pure checkLabel with six mandatory gaps and four warnings, AFM font metrics, Berlin-day dates, geometric reading order for split addresses, new master-data fields with row-locked updates. - Herd events ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached (ids only, never forwarded to webhooks); CSV import emits ColonyCreated with audit. - ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8a31c42578
commit
c18860c75c
73 files changed
+7121
-309
No files matched your search
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { ApiariesController } from './apiaries.controller';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutopilotModule],
|
||||
imports: [AutopilotModule, DomainEventsModule],
|
||||
controllers: [ApiariesController],
|
||||
providers: [ApiariesService, ApiaryFieldService],
|
||||
exports: [ApiariesService],
|
||||
|
||||
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiariesService } from './apiaries.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||
|
||||
describe('ApiariesService', () => {
|
||||
let service: ApiariesService;
|
||||
let prisma: {
|
||||
apiary: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let order: string[];
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||
colony: { count: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
const existingApiary = {
|
||||
id: 'apiary-1',
|
||||
name: 'Heimstand',
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
address: 'Weg 1',
|
||||
location: 'Garten',
|
||||
latitude: 48.1,
|
||||
longitude: 11.5,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
const changes = (): string[] =>
|
||||
(
|
||||
events.record.mock.calls as [unknown, { payload: { change: string } }][]
|
||||
).map(([, event]) => event.payload.change);
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
order = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve([{ id: 'apiary-1' }]);
|
||||
}),
|
||||
apiary: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(existingApiary);
|
||||
}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
prisma = {
|
||||
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiariesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
// Whatever a test recorded, the herd payloads carry only ids.
|
||||
afterEach(() => {
|
||||
expectOnlyIdPayloads(events.record.mock.calls);
|
||||
});
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('create persists the apiary and records an audit entry', async () => {
|
||||
prisma.apiary.create.mockResolvedValue(existingApiary);
|
||||
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
|
||||
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.create({ name: 'Heimstand' }, 'user-1');
|
||||
|
||||
expect(result).toEqual(existingApiary);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Apiary',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ApiaryChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: 'apiary-1',
|
||||
actorUserId: 'user-1',
|
||||
payload: { change: 'CREATED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing audit write rolls back the apiary change', async () => {
|
||||
tx.apiary.create.mockResolvedValue(existingApiary);
|
||||
auditLog.record.mockRejectedValue(new Error('audit down'));
|
||||
|
||||
await expect(
|
||||
service.create({ name: 'Heimstand' }, 'user-1'),
|
||||
).rejects.toThrow('audit down');
|
||||
// Record, audit and event share one transaction callback: its rejection
|
||||
// is what rolls the record back.
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('update reads before after locking the Apiary row', async () => {
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
|
||||
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: existingApiary,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
it('update of a missing apiary answers 404', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ address: 'Neuer Weg 2' }],
|
||||
[{ location: 'Wiese' }],
|
||||
[{ latitude: 50 }],
|
||||
[{ longitude: 12 }],
|
||||
])('update records LOCATION_CHANGED for %j', async (dto) => {
|
||||
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
|
||||
|
||||
await service.update('apiary-1', dto, 'user-1');
|
||||
|
||||
expect(changes()).toEqual(['LOCATION_CHANGED']);
|
||||
});
|
||||
|
||||
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
|
||||
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
|
||||
await service.update(
|
||||
'apiary-1',
|
||||
{ name: 'Anders', notes: 'neu' },
|
||||
'user-1',
|
||||
);
|
||||
// The same value sent again is no change either.
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
|
||||
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
|
||||
tx.apiary.update.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('INACTIVE');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
await service.deactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(prisma.colony.count).toHaveBeenCalledWith({
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
expect(tx.colony.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'apiary-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(changes()).toEqual(['DEACTIVATED']);
|
||||
});
|
||||
|
||||
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
prisma.colony.count.mockResolvedValue(2);
|
||||
tx.colony.count.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.deactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.update).not.toHaveBeenCalled();
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate throws ConflictException when already active', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||
await expect(
|
||||
service.reactivate('apiary-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue({
|
||||
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
|
||||
tx.apiary.findUnique.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'INACTIVE',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
prisma.apiary.update.mockResolvedValue({
|
||||
...existingApiary,
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
});
|
||||
tx.apiary.update.mockResolvedValue(existingApiary);
|
||||
|
||||
const result = await service.reactivate('apiary-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(prisma.apiary.update).toHaveBeenCalledWith({
|
||||
expect(tx.apiary.update).toHaveBeenCalledWith({
|
||||
where: { id: 'apiary-1' },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(changes()).toEqual(['REACTIVATED']);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||
|
||||
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
|
||||
|
||||
// The fields a change of which counts as a new location of the apiary.
|
||||
const LOCATION_FIELDS = [
|
||||
'address',
|
||||
'location',
|
||||
'latitude',
|
||||
'longitude',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApiariesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
|
||||
@@ -43,15 +57,21 @@ export class ApiariesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
|
||||
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: apiary.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: apiary,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const apiary = await tx.apiary.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: apiary.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -59,69 +79,120 @@ export class ApiariesService {
|
||||
dto: UpdateApiaryDto,
|
||||
actingUserId: string,
|
||||
): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
|
||||
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
|
||||
}
|
||||
return apiary;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead.
|
||||
const activeColonyCount = await this.prisma.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||
// without this guard a colony can silently keep pointing at an
|
||||
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||
// "location" has vanished from the active apiary list). Move the
|
||||
// colonies elsewhere or dissolve them first instead. Counted under the
|
||||
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
|
||||
// into it that is still running finishes first.
|
||||
const activeColonyCount = await tx.colony.count({
|
||||
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||
});
|
||||
if (activeColonyCount > 0) {
|
||||
throw new ConflictException(
|
||||
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||
);
|
||||
}
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||
const before = await this.findById(id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
throw new ConflictException('Apiary is already active');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockApiary(tx, id);
|
||||
if (before.status === 'ACTIVE') {
|
||||
throw new ConflictException('Apiary is already active');
|
||||
}
|
||||
const apiary = await tx.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
|
||||
return apiary;
|
||||
});
|
||||
}
|
||||
|
||||
// Locks the apiary row and reads it afterwards: the result - not an earlier
|
||||
// read - is "before" for the comparison, the audit and the event.
|
||||
private async lockApiary(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<Apiary> {
|
||||
await lockRowForUpdate(tx, 'Apiary', id);
|
||||
const apiary = await tx.apiary.findUnique({ where: { id } });
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Apiary not found');
|
||||
}
|
||||
const apiary = await this.prisma.apiary.update({
|
||||
where: { id },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Apiary',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: apiary,
|
||||
});
|
||||
return apiary;
|
||||
}
|
||||
|
||||
private recordChange(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
change: ApiaryChange,
|
||||
actorUserId: string,
|
||||
): Promise<void> {
|
||||
return this.events.record(tx, {
|
||||
type: 'ApiaryChanged',
|
||||
aggregateType: 'Apiary',
|
||||
aggregateId: apiaryId,
|
||||
actorUserId,
|
||||
payload: { change },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { WithdrawalModule } from './withdrawal/withdrawal.module';
|
||||
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
@@ -132,6 +133,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
VeterinaryMedicinesModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -608,6 +608,7 @@ export class BackupService {
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
|
||||
// Restore deliberately emits no ColonyCreated (no domain events).
|
||||
for (const row of rowsOf('colony')) {
|
||||
await tx.colony.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -33,11 +34,6 @@ export interface BottlingBatchFilter {
|
||||
honeyBatchId?: string;
|
||||
}
|
||||
|
||||
/** Amounts are kept in kilograms with gram precision. */
|
||||
function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
const REMOVED_NOTE = 'Abfüllung entfernt';
|
||||
@@ -212,7 +208,9 @@ export class BottlingService {
|
||||
? await this.assertBottledProduct(dto.productId)
|
||||
: null;
|
||||
const resolved = resolveJarAndBestBefore(dto, article);
|
||||
const totalAmount = roundKg((resolved.jarSize * dto.jarCount) / 1000);
|
||||
const totalAmount = roundToThousandths(
|
||||
(resolved.jarSize * dto.jarCount) / 1000,
|
||||
);
|
||||
|
||||
const lotNumber =
|
||||
dto.lotNumber ??
|
||||
@@ -352,7 +350,7 @@ export class BottlingService {
|
||||
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||
const jarSize = dto.jarSize ?? before.jarSize;
|
||||
const jarCount = dto.jarCount ?? before.jarCount;
|
||||
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||
const totalAmount = roundToThousandths((jarSize * jarCount) / 1000);
|
||||
|
||||
if (movedToOtherHoneyBatch) {
|
||||
// Both batches in one statement in ascending id order, so two
|
||||
@@ -392,7 +390,7 @@ export class BottlingService {
|
||||
await this.book(
|
||||
tx,
|
||||
honeyBatchId,
|
||||
roundKg(before.totalAmount - totalAmount),
|
||||
roundToThousandths(before.totalAmount - totalAmount),
|
||||
actingUserId,
|
||||
{ sourceBottlingBatchId: id },
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
|
||||
|
||||
describe('ColoniesService', () => {
|
||||
let service: ColoniesService;
|
||||
@@ -194,9 +195,12 @@ describe('ColoniesService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[, event]] = events.record.mock.calls as [
|
||||
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
|
||||
];
|
||||
const event = (
|
||||
events.record.mock.calls as [
|
||||
unknown,
|
||||
{ type: string; payload: { queenSource: unknown; splitOn: string } },
|
||||
][]
|
||||
).find(([, recorded]) => recorded.type === 'ColonySplit')![1];
|
||||
expect(event.payload.queenSource).toBeNull();
|
||||
expect(event.payload.splitOn).toBe(berlinToday());
|
||||
});
|
||||
@@ -514,6 +518,110 @@ describe('ColoniesService', () => {
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('herd events', () => {
|
||||
// Whatever a test recorded, the herd payloads carry only ids.
|
||||
afterEach(() => {
|
||||
expectOnlyIdPayloads(events.record.mock.calls);
|
||||
});
|
||||
|
||||
function recorded(type: string): unknown[] {
|
||||
return (events.record.mock.calls as [unknown, { type: string }][]).filter(
|
||||
([, event]) => event.type === type,
|
||||
);
|
||||
}
|
||||
|
||||
it('create records ColonyCreated with the apiary inside the transaction', async () => {
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-9',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
|
||||
await service.create(
|
||||
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-9',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('split records ColonyCreated for the child next to ColonySplit', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.create.mockResolvedValue({
|
||||
id: 'colony-2',
|
||||
apiaryId: 'apiary-1',
|
||||
});
|
||||
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||
|
||||
await service.split(
|
||||
'colony-1',
|
||||
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const types = (
|
||||
events.record.mock.calls as [unknown, { type: string }][]
|
||||
).map(([, event]) => event.type);
|
||||
expect(types).toEqual(
|
||||
expect.arrayContaining(['ColonySplit', 'ColonyCreated']),
|
||||
);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('move records ColonyMoved with from and to', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyMoved',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
actorUserId: 'user-1',
|
||||
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('update with a changed apiary records ColonyMoved and without one records none', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
expect(recorded('ColonyMoved')).toHaveLength(1);
|
||||
expect(events.record).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
type: 'ColonyMoved',
|
||||
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
events.record.mockClear();
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
await service.update('colony-1', { notes: 'x' }, 'user-1');
|
||||
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
|
||||
expect(recorded('ColonyMoved')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
import { colonyCreatedEvent } from './colony-created-event';
|
||||
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
@@ -23,6 +25,21 @@ import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
|
||||
function colonyMovedEvent(
|
||||
colonyId: string,
|
||||
fromApiaryId: string,
|
||||
toApiaryId: string,
|
||||
actorUserId: string,
|
||||
): AnyNewDomainEvent<'ColonyMoved'> {
|
||||
return {
|
||||
type: 'ColonyMoved',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
actorUserId,
|
||||
payload: { fromApiaryId, toApiaryId },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColonyFilter {
|
||||
status?: ColonyStatus;
|
||||
apiaryId?: string;
|
||||
@@ -96,10 +113,10 @@ export class ColoniesService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
return created;
|
||||
});
|
||||
// No domain event for a new colony in this stage - the webhook stays on
|
||||
// the direct path.
|
||||
// The webhook stays on the direct path (the event is not forwarded).
|
||||
await this.webhooks.dispatch('colony.created', colony);
|
||||
return colony;
|
||||
}
|
||||
@@ -159,6 +176,10 @@ export class ColoniesService {
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
await this.events.record(
|
||||
tx,
|
||||
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
|
||||
);
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
@@ -204,6 +225,12 @@ export class ColoniesService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
if (before.apiaryId !== updated.apiaryId) {
|
||||
await this.events.record(
|
||||
tx,
|
||||
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
|
||||
);
|
||||
}
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
@@ -263,6 +290,7 @@ export class ColoniesService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
await this.events.record(tx, {
|
||||
type: 'ColonySplit',
|
||||
aggregateType: 'Colony',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Colony } from '../../generated/prisma';
|
||||
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
|
||||
/**
|
||||
* The ColonyCreated event of a colony that was just written (create, split
|
||||
* child, CSV import). The caller records it in the transaction that wrote the
|
||||
* colony. Backup restore deliberately records none.
|
||||
*/
|
||||
export function colonyCreatedEvent(
|
||||
colony: Pick<Colony, 'id' | 'apiaryId'>,
|
||||
actorUserId: string,
|
||||
): AnyNewDomainEvent<'ColonyCreated'> {
|
||||
return {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
actorUserId,
|
||||
payload: { apiaryId: colony.apiaryId },
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,18 @@ describe('legacy after-commit paths', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('apiaries.service.ts has no after-commit side effects', () => {
|
||||
for (const call of [
|
||||
'runAfterCommit(',
|
||||
'webhooks.dispatch(',
|
||||
'calendarSync.enqueue(',
|
||||
]) {
|
||||
expect(filesCalling(call)).not.toContain(
|
||||
'src/apiaries/apiaries.service.ts',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
|
||||
const removed = [
|
||||
'evaluateInspection',
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { roundToThousandths } from './quantity';
|
||||
|
||||
describe('roundToThousandths', () => {
|
||||
it('rounds to thousandths', () => {
|
||||
expect(roundToThousandths(0.0004)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps 1.2345 as 1.235 (half up)', () => {
|
||||
expect(roundToThousandths(1.2345)).toBe(1.235);
|
||||
});
|
||||
|
||||
it('rounds ties towards +Infinity and never returns -0', () => {
|
||||
expect(roundToThousandths(-0.0005)).toBe(0);
|
||||
expect(Object.is(roundToThousandths(-0.0005), 0)).toBe(true);
|
||||
expect(Object.is(roundToThousandths(-0.0004), 0)).toBe(true);
|
||||
expect(roundToThousandths(1.2345)).toBe(1.235);
|
||||
expect(roundToThousandths(-1.2345)).toBe(-1.234);
|
||||
});
|
||||
|
||||
it('cleans float noise of a sum', () => {
|
||||
expect(roundToThousandths(0.1 + 0.2)).toBe(0.3);
|
||||
});
|
||||
|
||||
it('keeps negative values', () => {
|
||||
expect(roundToThousandths(-3.3333)).toBe(-3.333);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Quantities (kilograms of honey, millilitres of a medicine, ...) are kept on
|
||||
* a grid of thousandths, so sums of floats compare and print cleanly.
|
||||
*/
|
||||
export function roundToThousandths(value: number): number {
|
||||
// `+ 0` turns -0 (a tiny negative value) into 0.
|
||||
return Math.round(value * 1000) / 1000 + 0;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export class DocumentsController {
|
||||
findAll(
|
||||
@Query('entityType') entityType: string,
|
||||
@Query('entityId') entityId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (!entityType || !entityId) {
|
||||
throw new BadRequestException('entityType and entityId are required');
|
||||
@@ -57,6 +58,7 @@ export class DocumentsController {
|
||||
return this.documentsService.findAll(
|
||||
entityType as DocumentEntityType,
|
||||
entityId,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,9 +99,15 @@ export class DocumentsController {
|
||||
|
||||
@RequirePermission('documents:read')
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const { buffer, fileName, mimeType } =
|
||||
await this.documentsService.readFile(id);
|
||||
async download(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, fileName, mimeType } = await this.documentsService.readFile(
|
||||
id,
|
||||
actor.id,
|
||||
);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { DocumentsService } from './documents.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
@@ -21,7 +27,11 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.Mock };
|
||||
inspection: { findUnique: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: { findUnique: jest.Mock };
|
||||
authorityNotice: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findUnique: jest.Mock };
|
||||
};
|
||||
let permissions: { getPermissions: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const document = {
|
||||
@@ -50,11 +60,18 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.fn() },
|
||||
inspection: { findUnique: jest.fn() },
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
veterinaryMedicineLot: { findUnique: jest.fn() },
|
||||
authorityNotice: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findUnique: jest.fn() },
|
||||
};
|
||||
permissions = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set<string>()),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new DocumentsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissions as unknown as PermissionResolverService,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
@@ -64,7 +81,7 @@ describe('DocumentsService', () => {
|
||||
|
||||
it('findAll strips storagePath from results', async () => {
|
||||
prisma.document.findMany.mockResolvedValueOnce([document]);
|
||||
const result = await service.findAll('COLONY', 'colony-1');
|
||||
const result = await service.findAll('COLONY', 'colony-1', 'user-1');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'doc-1',
|
||||
@@ -143,14 +160,14 @@ describe('DocumentsService', () => {
|
||||
describe('readFile', () => {
|
||||
it('throws NotFoundException when the document does not exist', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(null);
|
||||
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(
|
||||
service.readFile('missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('reads the stored file from disk', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
const result = await service.readFile('doc-1');
|
||||
const result = await service.readFile('doc-1', 'user-1');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
|
||||
expect(result.fileName).toBe('bild.jpg');
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
@@ -178,6 +195,21 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete file cleanup', () => {
|
||||
it('still deletes the row and warns when the file cannot be removed', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
(fs.unlink as jest.Mock).mockRejectedValueOnce(new Error('EPERM'));
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(service.delete('doc-1', 'user-1')).resolves.toBeUndefined();
|
||||
expect(prisma.document.delete).toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('doc-1'));
|
||||
expect(auditLog.record).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const existing = {
|
||||
id: 'doc-1',
|
||||
@@ -236,4 +268,178 @@ describe('DocumentsService', () => {
|
||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-type permissions', () => {
|
||||
beforeEach(() => {
|
||||
// the fs module mock is shared between tests; start each with no calls
|
||||
jest.mocked(fs.writeFile).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
jest.mocked(fs.unlink).mockClear();
|
||||
});
|
||||
|
||||
const grant = (...keys: string[]) =>
|
||||
permissions.getPermissions.mockResolvedValue(new Set(keys));
|
||||
const lotDocument = {
|
||||
...document,
|
||||
entityType: 'MEDICINE_LOT',
|
||||
entityId: 'lot-1',
|
||||
};
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('x'),
|
||||
]);
|
||||
const pngFile = {
|
||||
buffer: png,
|
||||
originalname: 'b.png',
|
||||
mimetype: 'image/png',
|
||||
size: png.length,
|
||||
} as Express.Multer.File;
|
||||
|
||||
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', async () => {
|
||||
grant(
|
||||
'documents:upload',
|
||||
'veterinary-medicines:update',
|
||||
'compliance:manage',
|
||||
);
|
||||
prisma.document.create.mockResolvedValue(document);
|
||||
prisma.veterinaryMedicineLot.findUnique.mockResolvedValueOnce({
|
||||
id: 'l',
|
||||
});
|
||||
prisma.authorityNotice.findUnique.mockResolvedValueOnce({ id: 'n' });
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValueOnce({ id: 'r' });
|
||||
|
||||
await service.create(pngFile, 'MEDICINE_LOT', 'l', 'user-1');
|
||||
await service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1');
|
||||
await service.create(pngFile, 'APIARY_RESTRICTION', 'r', 'user-1');
|
||||
expect(prisma.veterinaryMedicineLot.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'l' },
|
||||
});
|
||||
expect(prisma.authorityNotice.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'n' },
|
||||
});
|
||||
expect(prisma.apiaryRestriction.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'r' },
|
||||
});
|
||||
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.create(pngFile, type, 'missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
}
|
||||
});
|
||||
|
||||
it('assertEntityAccess is a no-op for the old entity types', async () => {
|
||||
for (const type of [
|
||||
'APIARY',
|
||||
'COLONY',
|
||||
'QUEEN',
|
||||
'INSPECTION',
|
||||
'HONEY_BATCH',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'read'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'write'),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
expect(permissions.getPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assertEntityAccess maps each new type to its read and write right', async () => {
|
||||
grant('veterinary-medicines:read', 'compliance:read');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'read');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'read');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'read');
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('u', type, 'write'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
}
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'write');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'write');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'write');
|
||||
grant();
|
||||
await expect(
|
||||
service.assertEntityAccess('u', 'MEDICINE_LOT', 'read'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('findAll denies a restricted type before any database query', async () => {
|
||||
grant('documents:read');
|
||||
await expect(
|
||||
service.findAll('MEDICINE_LOT', 'lot-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create denies a restricted type before any check or write', async () => {
|
||||
grant('documents:upload');
|
||||
await expect(
|
||||
service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.authorityNotice.findUnique).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('readFile denies before reading the file from disk', async () => {
|
||||
grant('documents:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.readFile('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete denies before touching the row or the file', async () => {
|
||||
grant('documents:delete', 'veterinary-medicines:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.delete('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(prisma.document.delete).not.toHaveBeenCalled();
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update checks the permission of the old and of the new entity type', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue({ id: 'r' });
|
||||
prisma.document.update.mockResolvedValue(document);
|
||||
const relink = {
|
||||
entityType: 'APIARY_RESTRICTION' as const,
|
||||
entityId: 'r',
|
||||
};
|
||||
|
||||
grant('veterinary-medicines:update'); // old allowed, new not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
grant('compliance:manage'); // new allowed, old not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.update).not.toHaveBeenCalled();
|
||||
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.update('doc-1', relink, 'user-1');
|
||||
expect(prisma.document.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update of a restricted document without the right is denied even for a rename', async () => {
|
||||
grant('documents:update');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(
|
||||
service.update('doc-1', { fileName: 'x.pdf' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
@@ -9,12 +11,32 @@ import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { Document, DocumentEntityType } from '../../generated/prisma';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
||||
import { UpdateDocumentDto } from './dto/update-document.dto';
|
||||
|
||||
export type DocumentSummary = Omit<Document, 'storagePath'>;
|
||||
|
||||
/**
|
||||
* Documents of these entity types are specialist records (purchase receipts,
|
||||
* authority notices, restriction orders): on top of the generic documents:*
|
||||
* right the caller needs the right of the record they belong to. Types
|
||||
* without an entry have no additional requirement.
|
||||
*/
|
||||
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<
|
||||
Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>
|
||||
> = {
|
||||
MEDICINE_LOT: {
|
||||
read: 'veterinary-medicines:read',
|
||||
write: 'veterinary-medicines:update',
|
||||
},
|
||||
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
};
|
||||
|
||||
function storageRoot(): string {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -34,15 +56,36 @@ function toSummary(document: Document): DocumentSummary {
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
private readonly logger = new Logger(DocumentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
/** 403 unless the user holds the type's specialist right (see map above). */
|
||||
async assertEntityAccess(
|
||||
userId: string,
|
||||
entityType: DocumentEntityType,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<void> {
|
||||
const required = DOCUMENT_ENTITY_PERMISSIONS[entityType]?.[mode];
|
||||
if (!required) {
|
||||
return;
|
||||
}
|
||||
const granted = await this.permissions.getPermissions(userId);
|
||||
if (!granted.has(required)) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(
|
||||
entityType: DocumentEntityType,
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary[]> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'read');
|
||||
const documents = await this.prisma.document.findMany({
|
||||
where: { entityType, entityId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -70,6 +113,18 @@ export class DocumentsService {
|
||||
return this.prisma.honeyBatch.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'MEDICINE_LOT':
|
||||
return this.prisma.veterinaryMedicineLot.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'AUTHORITY_NOTICE':
|
||||
return this.prisma.authorityNotice.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'APIARY_RESTRICTION':
|
||||
return this.prisma.apiaryRestriction.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -85,6 +140,7 @@ export class DocumentsService {
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
}
|
||||
@@ -125,6 +181,7 @@ export class DocumentsService {
|
||||
|
||||
async readFile(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||
const document = await this.prisma.document.findUnique({
|
||||
where: { id },
|
||||
@@ -132,6 +189,7 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'read');
|
||||
const buffer = await fs.readFile(document.storagePath);
|
||||
return {
|
||||
buffer,
|
||||
@@ -154,8 +212,12 @@ export class DocumentsService {
|
||||
if (!before) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, before.entityType, 'write');
|
||||
const entityType = dto.entityType ?? before.entityType;
|
||||
const entityId = dto.entityId ?? before.entityId;
|
||||
if (entityType !== before.entityType) {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
}
|
||||
if (entityType !== before.entityType || entityId !== before.entityId) {
|
||||
await this.assertEntityExists(entityType, entityId);
|
||||
}
|
||||
@@ -181,8 +243,14 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'write');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await fs.unlink(document.storagePath).catch(() => undefined);
|
||||
await fs.unlink(document.storagePath).catch((error: unknown) => {
|
||||
// The row is gone; an orphaned (possibly restricted) file must be visible.
|
||||
this.logger.warn(
|
||||
`Could not remove the file of document ${id}: ${loggableError(error).message}`,
|
||||
);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Document',
|
||||
entityId: id,
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('DomainEventRegistry', () => {
|
||||
|
||||
it('rejects an event type outside the catalog', () => {
|
||||
class UnknownType {
|
||||
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
|
||||
@OnDomainEvent('ColonyWelcomed' as DomainEventType, 'colonies.welcome')
|
||||
handle(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ export interface DomainEventPayloads {
|
||||
previousSegment: SeasonSegment | null;
|
||||
source: PhaseSource | null;
|
||||
};
|
||||
// T6, aggregate Colony: a colony came into being (create, or the child of a
|
||||
// split). Ids only; not forwarded to webhooks.
|
||||
ColonyCreated: { apiaryId: string };
|
||||
// T6, aggregate Colony: a colony changed its apiary (move, or a correction
|
||||
// through update). Not forwarded to webhooks.
|
||||
ColonyMoved: { fromApiaryId: string; toApiaryId: string };
|
||||
// T6, aggregate Apiary: the herd-relevant master data of an apiary changed.
|
||||
// The code only, never the address or coordinates. Not forwarded to webhooks.
|
||||
ApiaryChanged: {
|
||||
change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED';
|
||||
};
|
||||
// T6, aggregate Compliance; time-based (dedupeKey per day): the TSK
|
||||
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
|
||||
// webhooks.
|
||||
TskReferenceDayReached: { year: number; referenceDay: string };
|
||||
}
|
||||
|
||||
export type DomainEventType = keyof DomainEventPayloads;
|
||||
@@ -169,6 +184,10 @@ const CATALOG: Record<DomainEventType, true> = {
|
||||
PhenologyRecomputeRequested: true,
|
||||
SeasonPhaseChanged: true,
|
||||
WinterCheckRecorded: true,
|
||||
ColonyCreated: true,
|
||||
ColonyMoved: true,
|
||||
ApiaryChanged: true,
|
||||
TskReferenceDayReached: true,
|
||||
};
|
||||
|
||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||
@@ -192,7 +211,8 @@ export type AggregateType =
|
||||
| 'HoneyBatch'
|
||||
| 'PhenologyReport'
|
||||
| 'WinterCheck'
|
||||
| 'Apiary';
|
||||
| 'Apiary'
|
||||
| 'Compliance';
|
||||
|
||||
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
|
||||
type: T;
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
||||
|
||||
describe('domain event catalog', () => {
|
||||
it('lists every event type exactly once', () => {
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
|
||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
|
||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -202,6 +202,10 @@ describe('domain event catalog', () => {
|
||||
'PhenologyRecomputeRequested',
|
||||
'SeasonPhaseChanged',
|
||||
'WinterCheckRecorded',
|
||||
'ColonyCreated',
|
||||
'ColonyMoved',
|
||||
'ApiaryChanged',
|
||||
'TskReferenceDayReached',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Allow-list of payload keys of the T6 herd events: ids, a change code, a year
|
||||
// and a day - never a name, address, coordinate or note.
|
||||
export const HERD_PAYLOAD_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
ColonyCreated: ['apiaryId'],
|
||||
ColonyMoved: ['fromApiaryId', 'toApiaryId'],
|
||||
ApiaryChanged: ['change'],
|
||||
TskReferenceDayReached: ['year', 'referenceDay'],
|
||||
};
|
||||
|
||||
const SAFE_VALUE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Checks the herd events among the `events.record` mock calls of a spec
|
||||
* (`[tx, event]` pairs) against the allow-list: no extra key, only id-shaped
|
||||
* strings or integers as values. Returns how many herd events it checked.
|
||||
*/
|
||||
export function expectOnlyIdPayloads(
|
||||
calls: readonly (readonly unknown[])[],
|
||||
): number {
|
||||
let checked = 0;
|
||||
for (const [, event] of calls as [unknown, unknown][]) {
|
||||
const { type, payload } = event as {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
const allowed = HERD_PAYLOAD_KEYS[type];
|
||||
if (allowed === undefined) {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
expect(Object.keys(payload).sort()).toEqual([...allowed].sort());
|
||||
for (const value of Object.values(payload)) {
|
||||
if (typeof value === 'number') {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
} else {
|
||||
expect(typeof value === 'string' && SAFE_VALUE.test(value)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { formatNumberDe } from '../suggestions/texts/format-number';
|
||||
import {
|
||||
WARNING_SEVERITY,
|
||||
@@ -25,9 +26,7 @@ export function sharedHoneyType(
|
||||
}
|
||||
|
||||
/** kg on a gram grid, so sums of floats compare and print cleanly. */
|
||||
export function roundKg(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
export const roundKg = roundToThousandths;
|
||||
|
||||
/**
|
||||
* The extraction (Schleuderung) against the net weight of its harvests
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { ImportService } from './import.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
|
||||
describe('ImportService', () => {
|
||||
let service: ImportService;
|
||||
@@ -9,19 +10,31 @@ describe('ImportService', () => {
|
||||
apiary: { create: jest.Mock; findFirst: jest.Mock };
|
||||
colony: { create: jest.Mock; findUnique: jest.Mock };
|
||||
queen: { create: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: { colony: { create: jest.Mock } };
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
apiary: { create: jest.fn(), findFirst: jest.fn() },
|
||||
colony: { create: jest.fn(), findUnique: jest.fn() },
|
||||
queen: { create: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
// The colony row is written through the transaction client; it shares the
|
||||
// mock with prisma.colony so the row tests read the same calls.
|
||||
tx = { colony: { create: prisma.colony.create } };
|
||||
prisma.$transaction.mockImplementation(
|
||||
(callback: (txArg: typeof tx) => unknown) => callback(tx),
|
||||
);
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ImportService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,6 +208,56 @@ describe('ImportService', () => {
|
||||
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
|
||||
});
|
||||
|
||||
it('records audit and ColonyCreated in the transaction of every created colony', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
prisma.colony.create
|
||||
.mockResolvedValueOnce({ id: 'colony-1', apiaryId: 'apiary-1' })
|
||||
.mockResolvedValueOnce({ id: 'colony-2', apiaryId: 'apiary-1' });
|
||||
|
||||
const report = await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,PRODUCTION_COLONY',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(report.created).toBe(2);
|
||||
expect(events.record).toHaveBeenCalledTimes(2);
|
||||
expect(events.record).toHaveBeenCalledWith(tx, {
|
||||
type: 'ColonyCreated',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
actorUserId: 'user-1',
|
||||
payload: { apiaryId: 'apiary-1' },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'Colony',
|
||||
entityId: 'colony-1',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('records no event for a skipped or invalid row', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
|
||||
|
||||
await service.importCsv(
|
||||
'colonies',
|
||||
Buffer.from(
|
||||
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,NOT_A_TYPE',
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(events.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a validation error for an invalid colonyType', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { plainToInstance } from 'class-transformer';
|
||||
import { validate, ValidationError } from 'class-validator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { colonyCreatedEvent } from '../colonies/colony-created-event';
|
||||
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
|
||||
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
|
||||
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
|
||||
@@ -56,6 +58,7 @@ export class ImportService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async importCsv(
|
||||
@@ -80,7 +83,9 @@ export class ImportService {
|
||||
record[column] = (dataRows[i][index] ?? '').trim();
|
||||
});
|
||||
const rowNumber = i + 1;
|
||||
results.push(await this.importRow(entity, record, rowNumber));
|
||||
results.push(
|
||||
await this.importRow(entity, record, rowNumber, actingUserId),
|
||||
);
|
||||
}
|
||||
|
||||
const report: ImportReport = {
|
||||
@@ -110,12 +115,13 @@ export class ImportService {
|
||||
entity: ImportEntity,
|
||||
record: Record<string, string>,
|
||||
rowNumber: number,
|
||||
actingUserId: string,
|
||||
): Promise<ImportRowResult> {
|
||||
switch (entity) {
|
||||
case 'apiaries':
|
||||
return this.importApiaryRow(record, rowNumber);
|
||||
case 'colonies':
|
||||
return this.importColonyRow(record, rowNumber);
|
||||
return this.importColonyRow(record, rowNumber, actingUserId);
|
||||
case 'queens':
|
||||
return this.importQueenRow(record, rowNumber);
|
||||
}
|
||||
@@ -150,6 +156,7 @@ export class ImportService {
|
||||
private async importColonyRow(
|
||||
record: Record<string, string>,
|
||||
rowNumber: number,
|
||||
actingUserId: string,
|
||||
): Promise<ImportRowResult> {
|
||||
const apiaryName = emptyToUndefined(record.apiaryName);
|
||||
if (!apiaryName) {
|
||||
@@ -209,7 +216,22 @@ export class ImportService {
|
||||
id: existing.id,
|
||||
};
|
||||
}
|
||||
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
||||
// Like ColoniesService.create: record, audit and event in one transaction.
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.colony.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
|
||||
return created;
|
||||
});
|
||||
return {
|
||||
row: rowNumber,
|
||||
status: 'created',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -91,4 +92,14 @@ export class CreateLabelTemplateDto {
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Kleinste x-Höhe (mm) für Pflichtangaben; darunter warnt die Etikettenprüfung. Vorgabe 1,2.',
|
||||
})
|
||||
@IsOptionalNotNull()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
@Max(5)
|
||||
minXHeightMm?: number;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fontMetricsOf, heightMm, pdfTextMeasurer } from './font-metrics';
|
||||
import { LabelTextElement } from './label-element.types';
|
||||
|
||||
describe('font metrics', () => {
|
||||
// Guards against a pdfkit update that changes or hides the AFM values.
|
||||
it('Helvetica metrics are x-height 523 and cap height 718', () => {
|
||||
expect(fontMetricsOf('sans', false, false)).toEqual({
|
||||
xHeight: 523,
|
||||
capHeight: 718,
|
||||
});
|
||||
});
|
||||
|
||||
it('Times and Courier return their own AFM values', () => {
|
||||
expect(fontMetricsOf('serif', false, false)).toEqual({
|
||||
xHeight: 450,
|
||||
capHeight: 662,
|
||||
});
|
||||
expect(fontMetricsOf('serif', true, true)).toEqual({
|
||||
xHeight: 462,
|
||||
capHeight: 669,
|
||||
});
|
||||
expect(fontMetricsOf('mono', false, false)).toEqual({
|
||||
xHeight: 426,
|
||||
capHeight: 562,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the style variants of a family', () => {
|
||||
expect(fontMetricsOf('sans', true, false).xHeight).toBe(532);
|
||||
});
|
||||
|
||||
it('converts a size and a metric to millimetres', () => {
|
||||
expect(heightMm(6, 523)).toBeCloseTo(1.107, 3);
|
||||
expect(heightMm(7, 523)).toBeCloseTo(1.2915, 3);
|
||||
expect(heightMm(72, 1000)).toBeCloseTo(25.4, 9);
|
||||
});
|
||||
|
||||
describe('pdfTextMeasurer', () => {
|
||||
const element: LabelTextElement = {
|
||||
id: 'e',
|
||||
type: 'text',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 20,
|
||||
height: 10,
|
||||
text: '',
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
it('measures more lines for a narrower box', () => {
|
||||
const measurer = pdfTextMeasurer();
|
||||
const text = 'Mindestens haltbar bis 05.03.2028 und noch mehr Text';
|
||||
const wide = measurer.heightOfStringPt(text, { ...element, width: 120 });
|
||||
const narrow = measurer.heightOfStringPt(text, { ...element, width: 20 });
|
||||
expect(narrow).toBeGreaterThan(wide);
|
||||
});
|
||||
|
||||
it('measures nothing for an empty text', () => {
|
||||
expect(pdfTextMeasurer().heightOfStringPt('', element)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { LabelFontFamily, LabelTextElement } from './label-element.types';
|
||||
|
||||
export const MM_TO_PT = 72 / 25.4;
|
||||
|
||||
/** Glyph metrics in AFM units (1/1000 of the font size). */
|
||||
export interface FontMetrics {
|
||||
xHeight: number;
|
||||
capHeight: number;
|
||||
}
|
||||
|
||||
export interface TextMeasurer {
|
||||
/** Height in pt the element's text needs at the element's width and font. */
|
||||
heightOfStringPt(text: string, element: LabelTextElement): number;
|
||||
}
|
||||
|
||||
/** The pdfkit standard font a family/style combination prints with. */
|
||||
export function pdfFontName(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): string {
|
||||
const base =
|
||||
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
|
||||
if (base === 'Times') {
|
||||
if (bold && italic) return 'Times-BoldItalic';
|
||||
if (bold) return 'Times-Bold';
|
||||
if (italic) return 'Times-Italic';
|
||||
return 'Times-Roman';
|
||||
}
|
||||
const suffix =
|
||||
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
/** Height in mm of a glyph class: size in pt x metric / 1000 x 25.4 / 72. */
|
||||
export function heightMm(fontSizePt: number, metricPer1000: number): number {
|
||||
return ((fontSizePt * metricPer1000) / 1000) * (25.4 / 72);
|
||||
}
|
||||
|
||||
// pdfkit keeps the loaded font in the private field `_font`; this is the one
|
||||
// place that reads it. font-metrics.spec.ts pins the values, so a pdfkit
|
||||
// update that moves or renames the field fails there and nowhere else.
|
||||
interface PdfDocumentInternals {
|
||||
_font?: { xHeight?: unknown; capHeight?: unknown };
|
||||
}
|
||||
|
||||
const metricsCache = new Map<string, FontMetrics>();
|
||||
|
||||
/** AFM x-height and cap height of a built-in PDF font. */
|
||||
export function fontMetricsOf(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): FontMetrics {
|
||||
const name = pdfFontName(family, bold, italic);
|
||||
const cached = metricsCache.get(name);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
doc.font(name);
|
||||
const font = (doc as unknown as PdfDocumentInternals)._font;
|
||||
const { xHeight, capHeight } = font ?? {};
|
||||
if (
|
||||
typeof xHeight !== 'number' ||
|
||||
typeof capHeight !== 'number' ||
|
||||
!(xHeight > 0) ||
|
||||
!(capHeight > 0)
|
||||
) {
|
||||
throw new Error(`No AFM metrics for font ${name}`);
|
||||
}
|
||||
const metrics = { xHeight, capHeight };
|
||||
metricsCache.set(name, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/** Measures text with a throw-away PDF document; nothing is written out. */
|
||||
export function pdfTextMeasurer(): TextMeasurer {
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
return {
|
||||
heightOfStringPt(text, element) {
|
||||
if (text === '') {
|
||||
return 0;
|
||||
}
|
||||
return doc
|
||||
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
|
||||
.fontSize(element.fontSize)
|
||||
.heightOfString(text, { width: element.width * MM_TO_PT });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import { UpdateProductDto } from '../products/dto/update-product.dto';
|
||||
import { UpdateSiteSettingsDto } from '../site-settings/dto/update-site-settings.dto';
|
||||
import { describeNullContracts } from '../common/validation/null-contract.spec-helper';
|
||||
|
||||
// Only the fields T6 Task 5 adds; the older fields stay in
|
||||
// update-dto-null-handling.spec.ts.
|
||||
describeNullContracts('Label particulars null contracts', {
|
||||
UpdateSiteSettingsDto: {
|
||||
dto: UpdateSiteSettingsDto,
|
||||
notNull: [],
|
||||
clearable: [
|
||||
'foodBusinessName',
|
||||
'foodBusinessAddress',
|
||||
'holderName',
|
||||
'holderAddress',
|
||||
],
|
||||
},
|
||||
UpdateProductDto: {
|
||||
dto: UpdateProductDto,
|
||||
notNull: [],
|
||||
clearable: ['legalName'],
|
||||
},
|
||||
UpdateLabelTemplateDto: {
|
||||
dto: UpdateLabelTemplateDto,
|
||||
notNull: ['minXHeightMm'],
|
||||
clearable: [],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { buildPlaceholderValues, labelDayOf } from './label-templates.service';
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
};
|
||||
|
||||
function values(bestBeforeDate: Date, date = bestBeforeDate) {
|
||||
return buildPlaceholderValues(
|
||||
{
|
||||
jarSize: 500,
|
||||
lotNumber: '2026-0001',
|
||||
date,
|
||||
bestBeforeDate,
|
||||
honeyBatch: { batchNumber: 'HB-1', honeyType: null },
|
||||
product: null,
|
||||
},
|
||||
SETTINGS,
|
||||
);
|
||||
}
|
||||
|
||||
// The expected days are literal Berlin days (CET in winter, CEST in summer),
|
||||
// so the cases hold whatever zone the test process runs in.
|
||||
describe('label days', () => {
|
||||
it.each([
|
||||
['midnight UTC, winter', '2027-03-01T00:00:00.000Z', '2027-03-01'],
|
||||
['midnight UTC, summer', '2027-07-01T00:00:00.000Z', '2027-07-01'],
|
||||
['23:30 UTC, winter', '2027-03-01T23:30:00.000Z', '2027-03-02'],
|
||||
['22:30 UTC, summer', '2027-06-30T22:30:00.000Z', '2027-07-01'],
|
||||
['21:30 UTC, summer', '2027-06-30T21:30:00.000Z', '2027-06-30'],
|
||||
['22:30 UTC, winter', '2027-02-28T22:30:00.000Z', '2027-02-28'],
|
||||
])('%s', (_name, instant, day) => {
|
||||
expect(labelDayOf(new Date(instant))).toBe(day);
|
||||
});
|
||||
|
||||
it('prints the best before day and line from the same day', () => {
|
||||
const result = values(new Date('2027-03-01T23:30:00.000Z'));
|
||||
expect(result.bestBeforeDate).toBe('02.03.2027');
|
||||
expect(result.bestBeforeLine).toBe('Mindestens haltbar bis 02.03.2027');
|
||||
});
|
||||
|
||||
it('prints the bottling date the same way', () => {
|
||||
const result = values(
|
||||
new Date('2028-03-01T00:00:00.000Z'),
|
||||
new Date('2026-03-01T23:30:00.000Z'),
|
||||
);
|
||||
expect(result.bottlingDate).toBe('02.03.2026');
|
||||
});
|
||||
|
||||
it('prints and checks the same day string', () => {
|
||||
const bestBefore = new Date('2027-03-01T00:00:00.000Z');
|
||||
const [year, month, day] = labelDayOf(bestBefore).split('-');
|
||||
expect(values(bestBefore).bestBeforeDate).toBe(`${day}.${month}.${year}`);
|
||||
});
|
||||
});
|
||||
|
||||
// A jest worker cannot change its own time zone, so the guarantee is by
|
||||
// construction: the date code must not read or build local-time dates. UTC
|
||||
// getters (getUTCDate, ...) and formatters with an explicit timeZone are fine.
|
||||
const FORBIDDEN = new RegExp(
|
||||
[
|
||||
'\\.(getDate|getDay|getMonth|getFullYear|getYear|getHours|getMinutes|getSeconds|getMilliseconds|getTimezoneOffset)\\(',
|
||||
'\\.set(Hours|Date|Month|FullYear|Minutes|Seconds)\\(',
|
||||
'\\.toDateString\\(',
|
||||
'toLocaleTimeString\\(',
|
||||
'toLocaleDateString\\((?![^)]*timeZone)',
|
||||
'toLocaleString\\((?![^)]*timeZone)',
|
||||
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
|
||||
// new Date(y, m, d, ...): several arguments build a local-time date
|
||||
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
|
||||
].join('|'),
|
||||
);
|
||||
|
||||
describe('local-time guard', () => {
|
||||
it.each([
|
||||
'date.getDate()',
|
||||
'date.getHours()',
|
||||
'date.getTimezoneOffset()',
|
||||
'date.getYear()',
|
||||
'date.getMilliseconds()',
|
||||
'date.setHours(0)',
|
||||
'date.setDate(1)',
|
||||
'date.toDateString()',
|
||||
"date.toLocaleDateString('de-DE')",
|
||||
"new Intl.DateTimeFormat('de-DE')",
|
||||
'new Date(2027, 2, 1)',
|
||||
])('flags %s', (sample) => {
|
||||
expect(sample).toMatch(FORBIDDEN);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"date.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })",
|
||||
"new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' })",
|
||||
'date.getUTCDate()',
|
||||
'date.getTime()',
|
||||
'new Date(Date.UTC(2027, 2, 1))',
|
||||
'new Date(`${day}T00:00:00.000Z`)',
|
||||
])('allows %s', (sample) => {
|
||||
expect(sample).not.toMatch(FORBIDDEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date sources avoid local-time APIs', () => {
|
||||
it.each([
|
||||
['label-templates.service.ts', __dirname],
|
||||
['label-particulars.ts', __dirname],
|
||||
['label-text.ts', __dirname],
|
||||
['font-metrics.ts', __dirname],
|
||||
['dates.ts', join(__dirname, '..', 'common')],
|
||||
['format-day.ts', join(__dirname, '..', 'common')],
|
||||
])('%s', (file, dir) => {
|
||||
expect(readFileSync(join(dir, file), 'utf8')).not.toMatch(FORBIDDEN);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,12 @@ export const PLACEHOLDER_KEYS = [
|
||||
'bestBeforeDate',
|
||||
'jarSize',
|
||||
'productName',
|
||||
'legalName',
|
||||
'originCountry',
|
||||
'foodBusinessName',
|
||||
'foodBusinessAddress',
|
||||
'bestBeforeLine',
|
||||
'lotLine',
|
||||
] as const;
|
||||
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
import { heightMm, TextMeasurer } from './font-metrics';
|
||||
import {
|
||||
checkLabel,
|
||||
LabelCheckInput,
|
||||
LabelFindingCode,
|
||||
NetQuantityDigitRule,
|
||||
SAMPLE_BOTTLING,
|
||||
} from './label-particulars';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
import { buildPlaceholderValues } from './label-templates.service';
|
||||
|
||||
// Mirrors the default of ComplianceSettings.netQuantityDigitRules.
|
||||
const RULES: NetQuantityDigitRule[] = [
|
||||
{ maxGrams: 50, minMm: 2 },
|
||||
{ maxGrams: 200, minMm: 3 },
|
||||
{ maxGrams: 1000, minMm: 4 },
|
||||
{ maxGrams: null, minMm: 6 },
|
||||
];
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
|
||||
};
|
||||
|
||||
const NO_CLIPPING: TextMeasurer = { heightOfStringPt: () => 0 };
|
||||
|
||||
function bottling(overrides: { jarSize?: number } = {}) {
|
||||
return {
|
||||
jarSize: overrides.jarSize ?? 500,
|
||||
lotNumber: '2026-0001',
|
||||
date: new Date('2026-03-05T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-03-05T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
|
||||
product: { name: 'Sommerhonig 500 g', legalName: 'Blütenhonig' },
|
||||
};
|
||||
}
|
||||
|
||||
function textElement(
|
||||
id: string,
|
||||
key: PlaceholderKey | null,
|
||||
y: number,
|
||||
overrides: Partial<LabelTextElement> = {},
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x: 2,
|
||||
y,
|
||||
width: 80,
|
||||
height: 10,
|
||||
text: '',
|
||||
placeholderKey: key,
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 20 pt Helvetica caps are 5.07 mm, above every default rule up to 1000 g.
|
||||
function fullLabel(): LabelTextElement[] {
|
||||
return [
|
||||
textElement('legal', 'legalName', 2),
|
||||
textElement('net', 'jarSize', 14, { fontSize: 20 }),
|
||||
textElement('bb', 'bestBeforeLine', 26),
|
||||
textElement('lot', 'lotLine', 38),
|
||||
textElement('origin', 'originCountry', 50),
|
||||
textElement('biz-name', 'foodBusinessName', 62),
|
||||
textElement('biz-address', 'foodBusinessAddress', 74),
|
||||
];
|
||||
}
|
||||
|
||||
function input(overrides: Partial<LabelCheckInput> = {}): LabelCheckInput {
|
||||
const jarSize = 500;
|
||||
return {
|
||||
elements: fullLabel(),
|
||||
minXHeightMm: 1.2,
|
||||
digitRules: RULES,
|
||||
values: buildPlaceholderValues(bottling({ jarSize }), SETTINGS),
|
||||
expected: {
|
||||
jarSizeGrams: jarSize,
|
||||
bestBefore: '2028-03-05',
|
||||
lotNumber: '2026-0001',
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: SETTINGS.foodBusinessName,
|
||||
foodBusinessAddress: SETTINGS.foodBusinessAddress,
|
||||
},
|
||||
sample: false,
|
||||
measurer: NO_CLIPPING,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function codes(findings: { code: LabelFindingCode }[]): LabelFindingCode[] {
|
||||
return findings.map((finding) => finding.code);
|
||||
}
|
||||
|
||||
function without(...ids: string[]): LabelTextElement[] {
|
||||
return fullLabel().filter((element) => !ids.includes(element.id));
|
||||
}
|
||||
|
||||
describe('checkLabel', () => {
|
||||
it('passes a label with all particulars', () => {
|
||||
const result = checkLabel(input());
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(result.printable).toBe(true);
|
||||
expect(result.sample).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['LEGAL_NAME', 'legal'],
|
||||
['NET_QUANTITY', 'net'],
|
||||
['BEST_BEFORE', 'bb'],
|
||||
['LOT', 'lot'],
|
||||
['ORIGIN', 'origin'],
|
||||
['FOOD_BUSINESS', 'biz-name'],
|
||||
] as const)('reports a missing %s by its code', (code, elementId) => {
|
||||
const elements =
|
||||
code === 'FOOD_BUSINESS'
|
||||
? without('biz-name', 'biz-address')
|
||||
: without(elementId);
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([code]);
|
||||
expect(result.printable).toBe(false);
|
||||
expect(result.gaps[0].title).not.toBe('');
|
||||
expect(result.gaps[0].detail).not.toBe('');
|
||||
});
|
||||
|
||||
it('matches the address across line breaks and separators', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz', null, 62, {
|
||||
text: 'Imkerei Muster\nBühlweg 10 · 86450 Altenmünster',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches an address that sits on two lines of one element', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10\n86450 Altenmünster',
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a static "500 g" on a 250 g bottling', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('net', null, 14, { text: 'Inhalt 500 g', fontSize: 20 }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements,
|
||||
values: buildPlaceholderValues(bottling({ jarSize: 250 }), SETTINGS),
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', () => {
|
||||
const decimal = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250,5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250.5 },
|
||||
}),
|
||||
);
|
||||
expect(codes(decimal.gaps)).toEqual([]);
|
||||
|
||||
const dot = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250.5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250.5 },
|
||||
}),
|
||||
);
|
||||
expect(codes(dot.gaps)).toEqual([]);
|
||||
|
||||
const kilogram = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 1000 },
|
||||
}),
|
||||
);
|
||||
expect(codes(kilogram.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not accept 1250 g for 250 g', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1250 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('does not accept "1 kg" for a jar that is not a full kilogram', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 1250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
describe('best before', () => {
|
||||
function withBestBefore(text: string): LabelTextElement[] {
|
||||
return [...without('bb'), textElement('bb', null, 26, { text })];
|
||||
}
|
||||
|
||||
it('needs the wording and the date, leading zeros do not matter', () => {
|
||||
for (const text of [
|
||||
'Mindestens haltbar bis 05.03.2028',
|
||||
'Mindestens haltbar bis 5.3.2028',
|
||||
'mindestens HALTBAR bis 5. 03. 2028',
|
||||
]) {
|
||||
expect(
|
||||
checkLabel(input({ elements: withBestBefore(text) })).gaps,
|
||||
).toEqual([]);
|
||||
}
|
||||
// wording without the date, date without the wording, a wrong date
|
||||
for (const text of [
|
||||
'Mindestens haltbar bis',
|
||||
'MHD 05.03.2028',
|
||||
'Mindestens haltbar bis 06.03.2028',
|
||||
'Mindestens haltbar bis 05.03.2029',
|
||||
]) {
|
||||
expect(
|
||||
codes(checkLabel(input({ elements: withBestBefore(text) })).gaps),
|
||||
).toEqual(['BEST_BEFORE']);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the wording and the date in separate elements', () => {
|
||||
const elements = [
|
||||
...without('bb'),
|
||||
textElement('bb-label', null, 26, { text: 'Mindestens haltbar bis' }),
|
||||
textElement('bb-date', 'bestBeforeDate', 32),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats clipped text as missing and warns TEXT_CLIPPED', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'lot' ? 999 : 0),
|
||||
};
|
||||
const result = checkLabel(input({ measurer }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(codes(result.warnings)).toEqual(['TEXT_CLIPPED']);
|
||||
expect(result.warnings[0].elementIds).toEqual(['lot']);
|
||||
expect(result.printable).toBe(false);
|
||||
});
|
||||
|
||||
it('clips with the real measurer when the box is too short for the text', () => {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', 'lotLine', 38, { height: 1 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer: undefined }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(codes(result.warnings)).toContain('TEXT_CLIPPED');
|
||||
});
|
||||
|
||||
it('does not warn TEXT_CLIPPED for clipped text that carries nothing', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'deco' ? 999 : 0),
|
||||
};
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('deco', null, 90, { text: 'Mit Liebe gemacht' }),
|
||||
];
|
||||
const result = checkLabel(input({ elements, measurer }));
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
describe('FOOD_BUSINESS', () => {
|
||||
it('is a gap when either master data field is empty', () => {
|
||||
for (const expected of [
|
||||
{ foodBusinessName: null },
|
||||
{ foodBusinessAddress: null },
|
||||
{ foodBusinessName: ' ' },
|
||||
{ foodBusinessAddress: '' },
|
||||
]) {
|
||||
const result = checkLabel(
|
||||
input({ expected: { ...input().expected, ...expected } }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a gap when only the name is on the label', () => {
|
||||
const result = checkLabel(input({ elements: without('biz-address') }));
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
});
|
||||
});
|
||||
|
||||
it('a label without product legalName is complete only with a fixed text containing "honig"', () => {
|
||||
const noLegalName = {
|
||||
...input().values,
|
||||
legalName: '',
|
||||
};
|
||||
expect(codes(checkLabel(input({ values: noLegalName })).gaps)).toEqual([
|
||||
'LEGAL_NAME',
|
||||
]);
|
||||
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('fixed', null, 90, { text: 'Echter Blütenhonig' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements, values: noLegalName })).gaps).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
describe('x-height', () => {
|
||||
function withOtherSize(fontSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('origin'),
|
||||
textElement('origin', 'originCountry', 50, { fontSize }),
|
||||
];
|
||||
}
|
||||
|
||||
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', () => {
|
||||
const small = checkLabel(input({ elements: withOtherSize(6) }));
|
||||
expect(small.gaps).toEqual([]);
|
||||
expect(codes(small.warnings)).toEqual(['X_HEIGHT']);
|
||||
expect(small.warnings[0].elementIds).toEqual(['origin']);
|
||||
expect(small.warnings[0].title).toBe('Text zu klein (x-Höhe 1,1 mm)');
|
||||
expect(small.printable).toBe(true);
|
||||
|
||||
const fine = checkLabel(input({ elements: withOtherSize(7) }));
|
||||
expect(fine.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('x-height exactly at the limit is fine', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: withOtherSize(6), minXHeightMm: heightMm(6, 523) }),
|
||||
);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('only checks elements that carry a particular', () => {
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('deco', null, 90, { text: 'Mit Liebe', fontSize: 4 }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('net quantity height', () => {
|
||||
function withNetSize(fontSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('net'),
|
||||
textElement('net', 'jarSize', 14, { fontSize }),
|
||||
];
|
||||
}
|
||||
|
||||
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', () => {
|
||||
const ok = checkLabel(input({ elements: withNetSize(15.8) }));
|
||||
expect(ok.warnings).toEqual([]);
|
||||
|
||||
const small = checkLabel(input({ elements: withNetSize(15.7) }));
|
||||
expect(codes(small.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
|
||||
expect(small.warnings[0].elementIds).toEqual(['net']);
|
||||
expect(small.printable).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[50, 2],
|
||||
[49.5, 2],
|
||||
[200, 3],
|
||||
[200.5, 4],
|
||||
[1000, 4],
|
||||
[1001, 6],
|
||||
[5000, 6],
|
||||
])(
|
||||
'uses the rule of the matching weight class for %s g (%s mm)',
|
||||
(grams, minMm) => {
|
||||
const result = checkLabel(
|
||||
input({ expected: { ...input().expected, jarSizeGrams: grams } }),
|
||||
);
|
||||
expect(result.requirements.netQuantityMinCapHeightMm).toBe(minMm);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the open class above the largest weight class', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '2 kg' },
|
||||
expected: { ...input().expected, jarSizeGrams: 2000 },
|
||||
}),
|
||||
);
|
||||
// 20 pt caps are 5.07 mm, the open class wants 6 mm
|
||||
expect(codes(result.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
|
||||
});
|
||||
|
||||
it('is satisfied when one of several mentions is large enough', () => {
|
||||
const elements = [
|
||||
...fullLabel(),
|
||||
textElement('net-small', null, 90, {
|
||||
text: 'Inhalt 500 g',
|
||||
fontSize: 8,
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts the rules itself and has no requirement without rules', () => {
|
||||
const shuffled = [RULES[3], RULES[1], RULES[0], RULES[2]];
|
||||
expect(
|
||||
checkLabel(input({ digitRules: shuffled })).requirements
|
||||
.netQuantityMinCapHeightMm,
|
||||
).toBe(4);
|
||||
const none = checkLabel(input({ digitRules: [] }));
|
||||
expect(none.requirements.netQuantityMinCapHeightMm).toBeNull();
|
||||
expect(none.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lot prefix', () => {
|
||||
it('warns on a lot without a leading L', () => {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text: 'Los 2026-0001' }),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(codes(result.warnings)).toEqual(['LOT_PREFIX']);
|
||||
});
|
||||
|
||||
it('accepts "L 2026-0001" and "L2026-0001"', () => {
|
||||
for (const text of ['L 2026-0001', 'L2026-0001', 'Ch.-B. L: 2026-0001']) {
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).warnings)).not.toContain(
|
||||
'LOT_PREFIX',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not add LOT_PREFIX when the lot is missing altogether', () => {
|
||||
const result = checkLabel(input({ elements: without('lot') }));
|
||||
expect(codes(result.gaps)).toEqual(['LOT']);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('a warning alone leaves printable true', () => {
|
||||
const elements = [
|
||||
...without('origin'),
|
||||
textElement('origin', 'originCountry', 50, { fontSize: 6 }),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.printable).toBe(true);
|
||||
});
|
||||
|
||||
it('sample result carries sample true', () => {
|
||||
expect(checkLabel(input({ sample: true })).sample).toBe(true);
|
||||
});
|
||||
|
||||
it('requirements carry minXHeightMm and the digit height of the jar size', () => {
|
||||
const result = checkLabel(input({ minXHeightMm: 1.5 }));
|
||||
expect(result.requirements).toEqual({
|
||||
minXHeightMm: 1.5,
|
||||
netQuantityMinCapHeightMm: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty label reports every mandatory particular in a fixed order', () => {
|
||||
const result = checkLabel(input({ elements: [] }));
|
||||
expect(codes(result.gaps)).toEqual([
|
||||
'LEGAL_NAME',
|
||||
'NET_QUANTITY',
|
||||
'BEST_BEFORE',
|
||||
'LOT',
|
||||
'ORIGIN',
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: robustness against odd layouts', () => {
|
||||
it('does not take "1,250 g" for 250 g', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '1,250 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
|
||||
it('does not take "250,5 g" for 5 g, but "Inhalt 6 x 500 g" for 500 g', () => {
|
||||
expect(
|
||||
codes(
|
||||
checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250,5 g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 5 },
|
||||
}),
|
||||
).gaps,
|
||||
),
|
||||
).toEqual(['NET_QUANTITY']);
|
||||
expect(
|
||||
checkLabel(
|
||||
input({ values: { ...input().values, jarSize: 'Inhalt 6 x 500 g' } }),
|
||||
).gaps,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not warn LOT_PREFIX for a lot that already starts with L', () => {
|
||||
const lotNumber = 'L-001';
|
||||
const elements = [
|
||||
...without('lot'),
|
||||
textElement('lot', null, 38, { text: 'L-001' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({ elements, expected: { ...input().expected, lotNumber } }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(codes(result.warnings)).not.toContain('LOT_PREFIX');
|
||||
});
|
||||
|
||||
describe('an address split over elements', () => {
|
||||
it('is found with another element sorted in between', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-1', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10',
|
||||
}),
|
||||
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
|
||||
textElement('biz-2', null, 74, { text: '86450 Altenmünster' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('is found when the parts differ slightly in y', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 62),
|
||||
textElement('biz-right', null, 70, {
|
||||
x: 40,
|
||||
text: '86450 Altenmünster',
|
||||
}),
|
||||
textElement('biz-left', null, 70.4, { text: 'Bühlweg 10' }),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('is still a gap when a part is really missing', () => {
|
||||
const elements = [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-1', null, 62, {
|
||||
text: 'Imkerei Muster, Bühlweg 10',
|
||||
}),
|
||||
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: round 2', () => {
|
||||
describe('the digit-before guard is per element', () => {
|
||||
it('ignores a lot number element right before the quantity', () => {
|
||||
const elements = [
|
||||
...without('lot', 'net'),
|
||||
textElement('lot', null, 38, { x: 2, width: 30, text: 'L 2026-0001' }),
|
||||
textElement('net', null, 38, {
|
||||
x: 40,
|
||||
width: 30,
|
||||
fontSize: 20,
|
||||
text: '500 g',
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a date element right before the quantity', () => {
|
||||
const elements = [
|
||||
...without('bb', 'net'),
|
||||
textElement('bb', null, 26, {
|
||||
x: 2,
|
||||
width: 50,
|
||||
text: 'Mindestens haltbar bis 05.03.2028',
|
||||
}),
|
||||
textElement('net', null, 26, {
|
||||
x: 60,
|
||||
width: 20,
|
||||
fontSize: 20,
|
||||
text: '500 g',
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects "1,250 g" inside one element', () => {
|
||||
const elements = [
|
||||
...without('net'),
|
||||
textElement('net', null, 14, { fontSize: 20, text: 'Inhalt 1,250 g' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements,
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a number glued to its unit ("250g")', () => {
|
||||
const result = checkLabel(
|
||||
input({
|
||||
values: { ...input().values, jarSize: '250g' },
|
||||
expected: { ...input().expected, jarSizeGrams: 250 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('a split address is bounded', () => {
|
||||
function split(
|
||||
street: { y: number; fontSize?: number },
|
||||
town: { y: number; fontSize?: number },
|
||||
): LabelTextElement[] {
|
||||
return [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 56),
|
||||
textElement('street', null, street.y, {
|
||||
text: 'Bühlweg 10',
|
||||
fontSize: street.fontSize ?? 10,
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, town.y, {
|
||||
text: '86450 Altenmünster',
|
||||
fontSize: town.fontSize ?? 10,
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
it('is a gap when the town sits in a footer far away', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 150 }) }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
});
|
||||
|
||||
it('is found when the town sits directly above the street', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 70 }, { y: 62 }) }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets the x-height warning reach the parts of the match', () => {
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 68, fontSize: 6 }) }),
|
||||
);
|
||||
expect(result.gaps).toEqual([]);
|
||||
const warning = result.warnings.find((w) => w.code === 'X_HEIGHT');
|
||||
expect(warning?.elementIds).toEqual(['town']);
|
||||
});
|
||||
|
||||
it('names a clipped part in the gap and in TEXT_CLIPPED', () => {
|
||||
const measurer: TextMeasurer = {
|
||||
heightOfStringPt: (_text, element) => (element.id === 'town' ? 999 : 0),
|
||||
};
|
||||
const result = checkLabel(
|
||||
input({ elements: split({ y: 62 }, { y: 68 }), measurer }),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
|
||||
expect(result.gaps[0].elementIds).toEqual(['town']);
|
||||
const clipped = result.warnings.find((w) => w.code === 'TEXT_CLIPPED');
|
||||
expect(clipped?.elementIds).toEqual(['town']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLabel: round 3', () => {
|
||||
function addressParts(
|
||||
street: { y: number; x?: number; text?: string },
|
||||
town: { y: number; x?: number },
|
||||
): LabelTextElement[] {
|
||||
return [
|
||||
textElement('street', null, street.y, {
|
||||
x: street.x ?? 2,
|
||||
text: street.text ?? 'Bühlweg 10',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, town.y, {
|
||||
x: town.x ?? 2,
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const base = () => [
|
||||
...without('biz-name', 'biz-address'),
|
||||
textElement('biz-name', 'foodBusinessName', 56),
|
||||
];
|
||||
|
||||
it('reading order comes from the geometry, not from the array order', () => {
|
||||
// a far away footer town FIRST in the array is still a gap
|
||||
const far = [...addressParts({ y: 62 }, { y: 150 }).reverse(), ...base()];
|
||||
expect(codes(checkLabel(input({ elements: far })).gaps)).toEqual([
|
||||
'FOOD_BUSINESS',
|
||||
]);
|
||||
// street/town in reverse array order but adjacent on the label
|
||||
const adjacent = [
|
||||
...addressParts({ y: 62 }, { y: 68 }).reverse(),
|
||||
...base(),
|
||||
];
|
||||
expect(checkLabel(input({ elements: adjacent })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('measures the distance against the whole group, not the last element', () => {
|
||||
// a tall street box, an empty box below it, the town further down: the
|
||||
// empty box alone would be 24 mm from the town, the street box is not
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('street', null, 62, {
|
||||
text: 'Bühlweg 10',
|
||||
height: 40,
|
||||
}),
|
||||
textElement('empty', null, 64, { text: '', height: 2 }),
|
||||
textElement('town', null, 90, {
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
expect(checkLabel(input({ elements })).gaps).toEqual([]);
|
||||
});
|
||||
|
||||
describe('number and unit in two elements', () => {
|
||||
function split(numberSize: number, unitSize: number): LabelTextElement[] {
|
||||
return [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
fontSize: numberSize,
|
||||
text: '500',
|
||||
}),
|
||||
textElement('unit', null, 14, {
|
||||
x: 24,
|
||||
width: 10,
|
||||
fontSize: unitSize,
|
||||
text: 'g',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
it('is found; the height warning looks at the parts', () => {
|
||||
const big = checkLabel(input({ elements: split(20, 20) }));
|
||||
expect(big.gaps).toEqual([]);
|
||||
expect(codes(big.warnings)).toEqual([]);
|
||||
|
||||
const small = checkLabel(input({ elements: split(8, 8) }));
|
||||
expect(small.gaps).toEqual([]);
|
||||
const warning = small.warnings.find(
|
||||
(w) => w.code === 'NET_QUANTITY_HEIGHT',
|
||||
);
|
||||
expect(warning?.elementIds).toEqual(['num', 'unit']);
|
||||
});
|
||||
|
||||
it('a big number next to a small unit counts the unit for x-height', () => {
|
||||
const result = checkLabel(input({ elements: split(20, 6) }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
const xHeight = result.warnings.find((w) => w.code === 'X_HEIGHT');
|
||||
expect(xHeight?.elementIds).toEqual(['unit']);
|
||||
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
|
||||
});
|
||||
|
||||
it('does not join across rows or onto a bigger number', () => {
|
||||
const otherRow = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { text: '500' }),
|
||||
textElement('unit', null, 30, { text: 'g' }),
|
||||
];
|
||||
expect(codes(checkLabel(input({ elements: otherRow })).gaps)).toEqual([
|
||||
'NET_QUANTITY',
|
||||
]);
|
||||
const bigger = [
|
||||
...without('net'),
|
||||
textElement('num', null, 14, { width: 20, text: '1,500' }),
|
||||
textElement('unit', null, 14, { x: 24, width: 10, text: 'g' }),
|
||||
];
|
||||
const result = checkLabel(
|
||||
input({
|
||||
elements: bigger,
|
||||
expected: { ...input().expected, jarSizeGrams: 500 },
|
||||
}),
|
||||
);
|
||||
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
|
||||
});
|
||||
});
|
||||
|
||||
it('gives only the elements of the match to the address carriers', () => {
|
||||
const elements = [
|
||||
...base(),
|
||||
textElement('lot-ref', null, 62, {
|
||||
x: 2,
|
||||
width: 20,
|
||||
fontSize: 5,
|
||||
text: 'Lot 10',
|
||||
}),
|
||||
textElement('street', null, 62, {
|
||||
x: 30,
|
||||
width: 25,
|
||||
text: 'Bühlweg 10',
|
||||
height: 5,
|
||||
}),
|
||||
textElement('town', null, 62, {
|
||||
x: 60,
|
||||
width: 25,
|
||||
text: '86450 Altenmünster',
|
||||
height: 5,
|
||||
}),
|
||||
];
|
||||
const result = checkLabel(input({ elements }));
|
||||
expect(result.gaps).toEqual([]);
|
||||
expect(result.warnings.filter((w) => w.code === 'X_HEIGHT')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAMPLE_BOTTLING', () => {
|
||||
it('carries example values only for the preview', () => {
|
||||
expect(SAMPLE_BOTTLING.jarSize).toBe(500);
|
||||
expect(SAMPLE_BOTTLING.lotNumber).toBe('2026-0001');
|
||||
const values = buildPlaceholderValues(SAMPLE_BOTTLING, SETTINGS);
|
||||
expect(values.jarSize).toBe('500 g');
|
||||
expect(values.lotLine).toBe('L 2026-0001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
import {
|
||||
fontMetricsOf,
|
||||
heightMm,
|
||||
MM_TO_PT,
|
||||
pdfTextMeasurer,
|
||||
TextMeasurer,
|
||||
} from './font-metrics';
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
import {
|
||||
lotStartsWithL,
|
||||
normalizeForCheck,
|
||||
resolvedTexts,
|
||||
ROW_TOLERANCE_MM,
|
||||
} from './label-text';
|
||||
|
||||
export type LabelFindingCode =
|
||||
| 'LEGAL_NAME'
|
||||
| 'NET_QUANTITY'
|
||||
| 'BEST_BEFORE'
|
||||
| 'LOT'
|
||||
| 'ORIGIN'
|
||||
| 'FOOD_BUSINESS'
|
||||
| 'LOT_PREFIX'
|
||||
| 'X_HEIGHT'
|
||||
| 'NET_QUANTITY_HEIGHT'
|
||||
| 'TEXT_CLIPPED';
|
||||
|
||||
export interface LabelFinding {
|
||||
code: LabelFindingCode;
|
||||
title: string;
|
||||
detail: string;
|
||||
elementIds: string[];
|
||||
}
|
||||
|
||||
export interface LabelCheckResult {
|
||||
printable: boolean;
|
||||
gaps: LabelFinding[];
|
||||
warnings: LabelFinding[];
|
||||
sample: boolean;
|
||||
/** The legal values the check used, so the frontend needs no own copy. */
|
||||
requirements: {
|
||||
minXHeightMm: number;
|
||||
netQuantityMinCapHeightMm: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetQuantityDigitRule {
|
||||
maxGrams: number | null;
|
||||
minMm: number;
|
||||
}
|
||||
|
||||
export interface LabelCheckInput {
|
||||
elements: readonly LabelTextElement[];
|
||||
minXHeightMm: number;
|
||||
digitRules: readonly NetQuantityDigitRule[];
|
||||
values: Record<PlaceholderKey, string>;
|
||||
expected: {
|
||||
jarSizeGrams: number;
|
||||
/** YYYY-MM-DD */
|
||||
bestBefore: string;
|
||||
lotNumber: string;
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
};
|
||||
sample: boolean;
|
||||
measurer?: TextMeasurer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example values for the preview when no bottling is chosen. They only fill
|
||||
* the template so it can be shown; they are not legal values.
|
||||
*/
|
||||
export const SAMPLE_BOTTLING = Object.freeze({
|
||||
jarSize: 500 as const,
|
||||
lotNumber: '2026-0001' as const,
|
||||
date: new Date('2026-07-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
|
||||
honeyBatch: Object.freeze({
|
||||
batchNumber: 'HB-2026-001',
|
||||
honeyType: 'Sommerhonig',
|
||||
}),
|
||||
product: Object.freeze({
|
||||
name: 'Sommerhonig 500 g',
|
||||
legalName: 'Sommerhonig',
|
||||
}),
|
||||
});
|
||||
|
||||
// A line that is at most half a point too tall still prints (spec 5.4).
|
||||
const CLIP_TOLERANCE_PT = 0.5;
|
||||
// Floating point slack for the "at least" comparisons.
|
||||
const HEIGHT_EPSILON_MM = 1e-9;
|
||||
|
||||
const BEST_BEFORE_WORDING = 'mindestens haltbar bis';
|
||||
const LEGAL_NAME_WORD = 'honig';
|
||||
|
||||
interface Candidate {
|
||||
element: LabelTextElement;
|
||||
normalized: string;
|
||||
clipped: boolean;
|
||||
}
|
||||
|
||||
interface Particular {
|
||||
code: Extract<
|
||||
LabelFindingCode,
|
||||
| 'LEGAL_NAME'
|
||||
| 'NET_QUANTITY'
|
||||
| 'BEST_BEFORE'
|
||||
| 'LOT'
|
||||
| 'ORIGIN'
|
||||
| 'FOOD_BUSINESS'
|
||||
>;
|
||||
/** Is the particular on the label, judged on the whole joined text? */
|
||||
isOnLabel(views: TextViews): boolean;
|
||||
/** Phrases that may be spread over several elements. */
|
||||
phrases?: string[];
|
||||
/** Matches spread over two elements, e.g. "500" | "g" (number first). */
|
||||
pairs?(views: TextViews): Candidate[][];
|
||||
/** Does one element's text hold (a part of) the particular? */
|
||||
isCarriedBy(normalized: string): boolean;
|
||||
missing: { title: string; detail: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The label text in the ways a particular may be spread over elements: every
|
||||
* element alone, all in reading order, row by row, and chains of nearby
|
||||
* elements that share a word with the phrase looked for.
|
||||
*/
|
||||
interface TextViews {
|
||||
visible: Candidate[];
|
||||
/** Normalized text of each visible element on its own. */
|
||||
elements: string[];
|
||||
full: string;
|
||||
/** Reading order, cut where two neighbours are far apart. */
|
||||
blocks: Candidate[][];
|
||||
rows: Candidate[][];
|
||||
chains(normalizedPhrase: string): Candidate[][];
|
||||
}
|
||||
|
||||
// Parts of a split phrase must be at most this far apart (edge to edge).
|
||||
const MAX_JOIN_GAP_MM = 10;
|
||||
|
||||
function hasPhrase(
|
||||
normalizedText: string,
|
||||
normalizedPhrase: string,
|
||||
notAfterDigit = false,
|
||||
): boolean {
|
||||
if (normalizedPhrase === '') {
|
||||
return false;
|
||||
}
|
||||
const padded = ` ${normalizedText} `;
|
||||
const needle = ` ${normalizedPhrase} `;
|
||||
let index = padded.indexOf(needle);
|
||||
while (index !== -1) {
|
||||
if (!notAfterDigit) {
|
||||
return true;
|
||||
}
|
||||
// "1,250 g" is not "250 g": a number token right before the amount means
|
||||
// the amount is only the tail of a bigger number. Only ever applied to
|
||||
// one element's text: a digit from a neighbouring element (lot, date)
|
||||
// says nothing about the amount.
|
||||
const before = padded.slice(0, index).split(' ').pop() ?? '';
|
||||
if (!/^\d+$/.test(before)) {
|
||||
return true;
|
||||
}
|
||||
index = padded.indexOf(needle, index + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function joined(candidates: readonly Candidate[]): string {
|
||||
return candidates
|
||||
.map((candidate) => candidate.normalized)
|
||||
.filter((text) => text !== '')
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function sharesWord(normalizedText: string, normalizedPhrase: string): boolean {
|
||||
const words = new Set(normalizedPhrase.split(' '));
|
||||
return normalizedText.split(' ').some((word) => words.has(word));
|
||||
}
|
||||
|
||||
interface Span {
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
// Symmetric vertical distance of an element to a span of elements: 0 when
|
||||
// they overlap vertically, whichever lies above.
|
||||
function distanceTo(span: Span, candidate: Candidate): number {
|
||||
const { y, height } = candidate.element;
|
||||
return Math.max(0, y - span.bottom, span.top - (y + height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a list (already in reading order) wherever the next element is more
|
||||
* than MAX_JOIN_GAP_MM away from everything gathered so far in the group
|
||||
* (the running top and bottom of the group, not only the previous element).
|
||||
*
|
||||
* Known limits, accepted: horizontal distance is ignored, and an unrelated
|
||||
* element that happens to share a word with the phrase can sit inside a chain.
|
||||
*/
|
||||
function groupByDistance(list: Candidate[]): Candidate[][] {
|
||||
const groups: Candidate[][] = [];
|
||||
let span: Span = { top: 0, bottom: 0 };
|
||||
for (const candidate of list) {
|
||||
const group = groups[groups.length - 1];
|
||||
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
|
||||
group.push(candidate);
|
||||
span = {
|
||||
top: Math.min(span.top, candidate.element.y),
|
||||
bottom: Math.max(
|
||||
span.bottom,
|
||||
candidate.element.y + candidate.element.height,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
groups.push([candidate]);
|
||||
span = {
|
||||
top: candidate.element.y,
|
||||
bottom: candidate.element.y + candidate.element.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function textViews(visible: Candidate[]): TextViews {
|
||||
const rows: Candidate[][] = [];
|
||||
let rowTop = Number.NEGATIVE_INFINITY;
|
||||
for (const candidate of visible) {
|
||||
if (candidate.element.y - rowTop > ROW_TOLERANCE_MM) {
|
||||
rows.push([]);
|
||||
rowTop = candidate.element.y;
|
||||
}
|
||||
rows[rows.length - 1].push(candidate);
|
||||
}
|
||||
return {
|
||||
visible,
|
||||
elements: visible.map((candidate) => candidate.normalized),
|
||||
full: joined(visible),
|
||||
blocks: groupByDistance(visible),
|
||||
rows,
|
||||
chains: (phrase) =>
|
||||
groupByDistance(
|
||||
visible.filter((candidate) => sharesWord(candidate.normalized, phrase)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The elements of `group` that make up the first occurrence of the phrase in
|
||||
* their joined text, or null. Only elements overlapping the occurrence
|
||||
* count, not neighbours that merely sit in the same group.
|
||||
*/
|
||||
function matchParts(group: Candidate[], phrase: string): Candidate[] | null {
|
||||
const parts = group.filter((candidate) => candidate.normalized !== '');
|
||||
const text = parts.map((candidate) => candidate.normalized).join(' ');
|
||||
const start = ` ${text} `.indexOf(` ${phrase} `);
|
||||
if (phrase === '' || start === -1) {
|
||||
return null;
|
||||
}
|
||||
const end = start + phrase.length;
|
||||
let offset = 0;
|
||||
const hit: Candidate[] = [];
|
||||
for (const part of parts) {
|
||||
const partEnd = offset + part.normalized.length;
|
||||
if (offset < end && partEnd > start) {
|
||||
hit.push(part);
|
||||
}
|
||||
offset = partEnd + 1;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of every group whose joined text holds the phrase: a block of
|
||||
* neighbours in reading order, one row, or a chain of nearby related elements
|
||||
* (a chain also in reverse order: a short address printed town-first is still
|
||||
* printed together).
|
||||
*/
|
||||
function groupsHolding(views: TextViews, phrase: string): Candidate[][] {
|
||||
const forward = [...views.blocks, ...views.rows, ...views.chains(phrase)];
|
||||
const reversed = views.chains(phrase).map((chain) => [...chain].reverse());
|
||||
return [...forward, ...reversed]
|
||||
.map((group) => matchParts(group, phrase))
|
||||
.filter((parts): parts is Candidate[] => parts !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* "500" | "g": an element ending in the amount (not glued to a bigger
|
||||
* number) followed, in the same row, by an element starting with the unit.
|
||||
*/
|
||||
function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
|
||||
const pairs: Candidate[][] = [];
|
||||
views.visible.forEach((first, index) => {
|
||||
const second = views.visible[index + 1];
|
||||
if (
|
||||
!second ||
|
||||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tokens = first.normalized.split(' ');
|
||||
const unitStart = second.normalized.split(' ')[0];
|
||||
for (const needle of needles) {
|
||||
const words = needle.split(' ');
|
||||
const unit = words.pop();
|
||||
const amount = tokens.slice(-words.length);
|
||||
const before = tokens[tokens.length - words.length - 1] ?? '';
|
||||
if (
|
||||
words.length > 0 &&
|
||||
unit === unitStart &&
|
||||
amount.join(' ') === words.join(' ') &&
|
||||
!/^\d+$/.test(before)
|
||||
) {
|
||||
pairs.push([first, second]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
function bestBeforeDatePattern(bestBefore: string): RegExp {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(bestBefore);
|
||||
if (!match) {
|
||||
throw new Error('bestBefore must be a YYYY-MM-DD date');
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
return new RegExp(`(^| )0?${Number(day)} 0?${Number(month)} ${year}( |$)`);
|
||||
}
|
||||
|
||||
function netQuantityNeedles(jarSizeGrams: number): string[] {
|
||||
const needles = [normalizeForCheck(`${jarSizeGrams} g`)];
|
||||
if (jarSizeGrams > 0 && jarSizeGrams % 1000 === 0) {
|
||||
needles.push(normalizeForCheck(`${jarSizeGrams / 1000} kg`));
|
||||
}
|
||||
return needles;
|
||||
}
|
||||
|
||||
function particulars(expected: LabelCheckInput['expected']): Particular[] {
|
||||
const netNeedles = netQuantityNeedles(expected.jarSizeGrams);
|
||||
const datePattern = bestBeforeDatePattern(expected.bestBefore);
|
||||
const lot = normalizeForCheck(expected.lotNumber);
|
||||
const origin = normalizeForCheck(expected.originCountry);
|
||||
const businessName = normalizeForCheck(expected.foodBusinessName ?? '');
|
||||
const businessAddress = normalizeForCheck(expected.foodBusinessAddress ?? '');
|
||||
const hasBusinessData = businessName !== '' && businessAddress !== '';
|
||||
const hasHoneyWord = (text: string) =>
|
||||
text.split(' ').some((word) => word.includes(LEGAL_NAME_WORD));
|
||||
|
||||
return [
|
||||
{
|
||||
code: 'LEGAL_NAME',
|
||||
isOnLabel: (views) => hasHoneyWord(views.full),
|
||||
isCarriedBy: hasHoneyWord,
|
||||
missing: {
|
||||
title: 'Verkehrsbezeichnung fehlt',
|
||||
detail:
|
||||
'Die Bezeichnung des Lebensmittels fehlt. Sie muss das Wort „Honig“ enthalten, zum Beispiel „Blütenhonig“. Tragen Sie sie am Artikel ein oder setzen Sie einen festen Text.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'NET_QUANTITY',
|
||||
pairs: (views) => quantityPairs(views, netNeedles),
|
||||
isOnLabel: (views) =>
|
||||
views.elements.some((text) =>
|
||||
netNeedles.some((needle) => hasPhrase(text, needle, true)),
|
||||
) || quantityPairs(views, netNeedles).length > 0,
|
||||
isCarriedBy: (text) =>
|
||||
netNeedles.some((needle) => hasPhrase(text, needle, true)),
|
||||
missing: {
|
||||
title: 'Füllmenge fehlt',
|
||||
detail: `Die Füllmenge dieser Abfüllung (${String(expected.jarSizeGrams).replace('.', ',')} g) steht nicht auf dem Etikett. Ein fester Text mit einer anderen Menge zählt nicht.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'BEST_BEFORE',
|
||||
isOnLabel: ({ full }) =>
|
||||
hasPhrase(full, BEST_BEFORE_WORDING) && datePattern.test(full),
|
||||
isCarriedBy: (text) =>
|
||||
hasPhrase(text, BEST_BEFORE_WORDING) || datePattern.test(text),
|
||||
missing: {
|
||||
title: 'Mindesthaltbarkeitsdatum fehlt',
|
||||
detail:
|
||||
'Es braucht den Text „Mindestens haltbar bis“ und das Datum dieser Abfüllung.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'LOT',
|
||||
isOnLabel: (views) => hasPhrase(views.full, lot),
|
||||
isCarriedBy: (text) => hasPhrase(text, lot),
|
||||
missing: {
|
||||
title: 'Losnummer fehlt',
|
||||
detail: 'Die Losnummer dieser Abfüllung steht nicht auf dem Etikett.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'ORIGIN',
|
||||
isOnLabel: (views) => hasPhrase(views.full, origin),
|
||||
isCarriedBy: (text) => hasPhrase(text, origin),
|
||||
missing: {
|
||||
title: 'Herkunftsland fehlt',
|
||||
detail:
|
||||
'Das Herkunftsland aus den Einstellungen steht nicht auf dem Etikett.',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'FOOD_BUSINESS',
|
||||
phrases: [businessName, businessAddress],
|
||||
isOnLabel: (views) =>
|
||||
hasBusinessData &&
|
||||
groupsHolding(views, businessName).length > 0 &&
|
||||
groupsHolding(views, businessAddress).length > 0,
|
||||
isCarriedBy: (text) =>
|
||||
hasPhrase(text, businessName) || hasPhrase(text, businessAddress),
|
||||
missing: {
|
||||
title: 'Name und Anschrift fehlen',
|
||||
detail: hasBusinessData
|
||||
? 'Name und Anschrift des Lebensmittelunternehmers aus den Einstellungen stehen nicht vollständig auf dem Etikett.'
|
||||
: 'Name und Anschrift des Lebensmittelunternehmers sind in den Einstellungen nicht eingetragen.',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function candidatesOf(
|
||||
input: LabelCheckInput,
|
||||
measurer: TextMeasurer,
|
||||
): Candidate[] {
|
||||
return resolvedTexts(input.elements, input.values).map(
|
||||
({ element, text }) => {
|
||||
const normalized = normalizeForCheck(text);
|
||||
const clipped =
|
||||
normalized !== '' &&
|
||||
measurer.heightOfStringPt(text, element) >
|
||||
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
|
||||
return { element, normalized, clipped };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function ruleFor(
|
||||
rules: readonly NetQuantityDigitRule[],
|
||||
grams: number,
|
||||
): NetQuantityDigitRule | null {
|
||||
const ordered = [...rules].sort(
|
||||
(a, b) =>
|
||||
(a.maxGrams ?? Number.POSITIVE_INFINITY) -
|
||||
(b.maxGrams ?? Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return (
|
||||
ordered.find((rule) => rule.maxGrams === null || grams <= rule.maxGrams) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function unique(ids: string[]): string[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function xHeightFinding(
|
||||
carriers: Candidate[],
|
||||
minXHeightMm: number,
|
||||
): LabelFinding | null {
|
||||
const measured = carriers.map(({ element }) => ({
|
||||
id: element.id,
|
||||
xHeightMm: heightMm(
|
||||
element.fontSize,
|
||||
fontMetricsOf(element.fontFamily, element.bold, element.italic).xHeight,
|
||||
),
|
||||
}));
|
||||
const tooSmall = measured.filter(
|
||||
(entry) => entry.xHeightMm < minXHeightMm - HEIGHT_EPSILON_MM,
|
||||
);
|
||||
if (tooSmall.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const smallest = Math.min(...tooSmall.map((entry) => entry.xHeightMm));
|
||||
return {
|
||||
code: 'X_HEIGHT',
|
||||
title: `Text zu klein (x-Höhe ${formatDecimal(smallest)} mm)`,
|
||||
detail: `Pflichtangaben brauchen eine x-Höhe von mindestens ${formatDecimal(minXHeightMm)} mm. Schrift vergrößern.`,
|
||||
elementIds: unique(tooSmall.map((entry) => entry.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function netQuantityHeightFinding(
|
||||
carriers: Candidate[],
|
||||
minMm: number | null,
|
||||
): LabelFinding | null {
|
||||
if (minMm === null || carriers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const tallest = Math.max(
|
||||
...carriers.map(({ element }) =>
|
||||
heightMm(
|
||||
element.fontSize,
|
||||
fontMetricsOf(element.fontFamily, element.bold, element.italic)
|
||||
.capHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (tallest >= minMm - HEIGHT_EPSILON_MM) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: 'NET_QUANTITY_HEIGHT',
|
||||
title: `Füllmenge zu klein (Ziffernhöhe ${formatDecimal(tallest)} mm)`,
|
||||
detail: `Für diese Füllmenge sind Ziffern von mindestens ${formatDecimal(minMm)} mm Höhe nötig. Schrift der Füllmenge vergrößern.`,
|
||||
elementIds: unique(carriers.map(({ element }) => element.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the label's text elements against the mandatory particulars for one
|
||||
* bottling. Pure: the caller resolves the legal values (settings, digit
|
||||
* rules) and the expected facts. Text that does not fit its box counts as
|
||||
* missing; the check says so with TEXT_CLIPPED.
|
||||
*/
|
||||
export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
||||
const measurer = input.measurer ?? pdfTextMeasurer();
|
||||
const candidates = candidatesOf(input, measurer);
|
||||
const visible = candidates.filter((candidate) => !candidate.clipped);
|
||||
const views = textViews(visible);
|
||||
const combined = views.full;
|
||||
|
||||
const gaps: LabelFinding[] = [];
|
||||
const carriers = new Set<Candidate>();
|
||||
const clippedCarriers = new Set<Candidate>();
|
||||
let netQuantityCarriers: Candidate[] = [];
|
||||
let netQuantityFound = false;
|
||||
let lotFound = false;
|
||||
let lotCarriers: Candidate[] = [];
|
||||
|
||||
for (const particular of particulars(input.expected)) {
|
||||
const isOnLabel = particular.isOnLabel(views);
|
||||
// Phrases no single element holds are spread over several: the parts that
|
||||
// make up the match carry the particular, and so do clipped parts.
|
||||
const splitPhrases = (particular.phrases ?? []).filter(
|
||||
(phrase) =>
|
||||
phrase !== '' &&
|
||||
!visible.some((candidate) => hasPhrase(candidate.normalized, phrase)),
|
||||
);
|
||||
const splitParts = isOnLabel
|
||||
? splitPhrases.flatMap((phrase) => groupsHolding(views, phrase).flat())
|
||||
: [];
|
||||
const pairs = particular.pairs?.(views) ?? [];
|
||||
const singleHolders = visible.filter((candidate) =>
|
||||
particular.isCarriedBy(candidate.normalized),
|
||||
);
|
||||
const holders = [
|
||||
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
|
||||
];
|
||||
holders.forEach((candidate) => carriers.add(candidate));
|
||||
const clippedHolders = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.clipped &&
|
||||
(particular.isCarriedBy(candidate.normalized) ||
|
||||
splitPhrases.some((phrase) =>
|
||||
sharesWord(candidate.normalized, phrase),
|
||||
)),
|
||||
);
|
||||
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
|
||||
if (!isOnLabel) {
|
||||
gaps.push({
|
||||
code: particular.code,
|
||||
...particular.missing,
|
||||
elementIds: unique(clippedHolders.map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
if (particular.code === 'NET_QUANTITY') {
|
||||
netQuantityFound = isOnLabel;
|
||||
netQuantityCarriers = [...new Set([...singleHolders, ...pairs.flat()])];
|
||||
}
|
||||
if (particular.code === 'LOT') {
|
||||
lotFound = isOnLabel;
|
||||
lotCarriers = holders;
|
||||
}
|
||||
}
|
||||
|
||||
const warnings: LabelFinding[] = [];
|
||||
|
||||
if (lotFound) {
|
||||
const lot = normalizeForCheck(input.expected.lotNumber);
|
||||
if (
|
||||
!lotStartsWithL(input.expected.lotNumber) &&
|
||||
!new RegExp(`(^| )l ?${lot}( |$)`).test(combined)
|
||||
) {
|
||||
warnings.push({
|
||||
code: 'LOT_PREFIX',
|
||||
title: 'Losnummer ohne „L“',
|
||||
detail:
|
||||
'Vor der Losnummer sollte der Buchstabe „L“ stehen, zum Beispiel „L 2026-0001“.',
|
||||
elementIds: unique(lotCarriers.map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const xHeight = xHeightFinding([...carriers], input.minXHeightMm);
|
||||
if (xHeight) {
|
||||
warnings.push(xHeight);
|
||||
}
|
||||
|
||||
const digitRule = ruleFor(input.digitRules, input.expected.jarSizeGrams);
|
||||
const netQuantityHeight = netQuantityFound
|
||||
? netQuantityHeightFinding(netQuantityCarriers, digitRule?.minMm ?? null)
|
||||
: null;
|
||||
if (netQuantityHeight) {
|
||||
warnings.push(netQuantityHeight);
|
||||
}
|
||||
|
||||
if (clippedCarriers.size > 0) {
|
||||
warnings.push({
|
||||
code: 'TEXT_CLIPPED',
|
||||
title: 'Text wird abgeschnitten',
|
||||
detail:
|
||||
'Der Text passt nicht in sein Feld und zählt deshalb nicht. Feld vergrößern oder Schrift verkleinern.',
|
||||
elementIds: unique([...clippedCarriers].map(({ element }) => element.id)),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
printable: gaps.length === 0,
|
||||
gaps,
|
||||
warnings,
|
||||
sample: input.sample,
|
||||
requirements: {
|
||||
minXHeightMm: input.minXHeightMm,
|
||||
netQuantityMinCapHeightMm: digitRule?.minMm ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SiteSettingsModule } from '../site-settings/site-settings.module';
|
||||
import { LabelTemplatesController } from './label-templates.controller';
|
||||
import { LabelTemplatesService } from './label-templates.service';
|
||||
|
||||
@Module({
|
||||
imports: [SiteSettingsModule],
|
||||
controllers: [LabelTemplatesController],
|
||||
providers: [LabelTemplatesService],
|
||||
exports: [LabelTemplatesService],
|
||||
|
||||
@@ -5,8 +5,20 @@ import {
|
||||
} from './label-templates.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { LabelElement } from './label-element.types';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const SETTINGS = {
|
||||
originCountry: 'Deutschland',
|
||||
foodBusinessName: 'Imkerei Muster' as string | null,
|
||||
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster' as string | null,
|
||||
};
|
||||
|
||||
describe('LabelTemplatesService', () => {
|
||||
let service: LabelTemplatesService;
|
||||
let tx: {
|
||||
@@ -14,8 +26,11 @@ describe('LabelTemplatesService', () => {
|
||||
updateMany: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
};
|
||||
};
|
||||
let siteSettings: { get: jest.Mock };
|
||||
const order: string[] = [];
|
||||
let prisma: {
|
||||
labelTemplate: {
|
||||
findMany: jest.Mock;
|
||||
@@ -78,11 +93,21 @@ describe('LabelTemplatesService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
siteSettings = { get: jest.fn().mockResolvedValue(SETTINGS) };
|
||||
tx = {
|
||||
labelTemplate: {
|
||||
updateMany: jest.fn().mockResolvedValue({}),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(template);
|
||||
}),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
@@ -100,6 +125,7 @@ describe('LabelTemplatesService', () => {
|
||||
service = new LabelTemplatesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
siteSettings as unknown as SiteSettingsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,6 +166,52 @@ describe('LabelTemplatesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('reads before after locking the LabelTemplate row', async () => {
|
||||
tx.labelTemplate.update.mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve({ ...template, name: 'Neu' });
|
||||
});
|
||||
|
||||
await service.update('template-1', { name: 'Neu' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'LabelTemplate',
|
||||
'template-1',
|
||||
);
|
||||
expect(order).toEqual(['lock', 'read', 'update']);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'UPDATE',
|
||||
beforeData: template,
|
||||
afterData: { ...template, name: 'Neu' },
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException for a missing template', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { name: 'Neu' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.labelTemplate.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears other defaults when isDefault is set', async () => {
|
||||
tx.labelTemplate.update.mockResolvedValue(template);
|
||||
|
||||
await service.update('template-1', { isDefault: true }, 'user-1');
|
||||
|
||||
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { not: 'template-1' } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('sets active and isDefault to false, records DEACTIVATE', async () => {
|
||||
prisma.labelTemplate.findUnique.mockResolvedValue(template);
|
||||
@@ -426,28 +498,62 @@ describe('LabelTemplatesService', () => {
|
||||
date: new Date('2026-06-01T00:00:00.000Z'),
|
||||
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
|
||||
product: { name: 'Glas 500g' },
|
||||
product: {
|
||||
name: 'Glas 500g',
|
||||
legalName: 'Blütenhonig' as string | null,
|
||||
},
|
||||
};
|
||||
|
||||
it('prints the jar size in grams, the unit honey is sold by', () => {
|
||||
expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g');
|
||||
});
|
||||
|
||||
it('never labels the jar size as a volume', () => {
|
||||
expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch(
|
||||
/\b(ml|l|cl)\b/,
|
||||
expect(buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize).toBe(
|
||||
'500 g',
|
||||
);
|
||||
});
|
||||
|
||||
it('never labels the jar size as a volume', () => {
|
||||
expect(
|
||||
buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize,
|
||||
).not.toMatch(/\b(ml|l|cl)\b/);
|
||||
});
|
||||
|
||||
it('leaves an unset honey type and product as empty text, not "null"', () => {
|
||||
const values = buildPlaceholderValues({
|
||||
...bottlingBatch,
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
|
||||
product: null,
|
||||
});
|
||||
const values = buildPlaceholderValues(
|
||||
{
|
||||
...bottlingBatch,
|
||||
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
|
||||
product: null,
|
||||
},
|
||||
SETTINGS,
|
||||
);
|
||||
|
||||
expect(values.honeyType).toBe('');
|
||||
expect(values.productName).toBe('');
|
||||
expect(values.legalName).toBe('');
|
||||
});
|
||||
|
||||
it('fills the particulars from the product and the settings', () => {
|
||||
const values = buildPlaceholderValues(bottlingBatch, SETTINGS);
|
||||
|
||||
expect(values.legalName).toBe('Blütenhonig');
|
||||
expect(values.originCountry).toBe('Deutschland');
|
||||
expect(values.foodBusinessName).toBe('Imkerei Muster');
|
||||
expect(values.foodBusinessAddress).toBe('Bühlweg 10, 86450 Altenmünster');
|
||||
expect(values.bestBeforeLine).toBe('Mindestens haltbar bis 01.06.2028');
|
||||
expect(values.lotLine).toBe('L-001');
|
||||
});
|
||||
|
||||
it('leaves unset master data as empty text, not "null"', () => {
|
||||
const values = buildPlaceholderValues(
|
||||
{
|
||||
...bottlingBatch,
|
||||
product: { name: 'Glas 500g', legalName: null },
|
||||
},
|
||||
{ ...SETTINGS, foodBusinessName: null, foodBusinessAddress: null },
|
||||
);
|
||||
|
||||
expect(values.legalName).toBe('');
|
||||
expect(values.foodBusinessName).toBe('');
|
||||
expect(values.foodBusinessAddress).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,20 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettingsService } from '../site-settings/site-settings.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { LabelTemplate } from '../../generated/prisma';
|
||||
import { computeCropMarks } from './crop-marks';
|
||||
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
|
||||
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
|
||||
import {
|
||||
LabelElement,
|
||||
LabelFontFamily,
|
||||
PlaceholderKey,
|
||||
} from './label-element.types';
|
||||
import { berlinDayOf } from '../common/dates';
|
||||
import { formatDayDe } from '../common/format-day';
|
||||
import { MM_TO_PT, pdfFontName } from './font-metrics';
|
||||
import { lotStartsWithL } from './label-text';
|
||||
import { LabelElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
const MM_TO_PT = 72 / 25.4;
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const QR_SOURCE_SIZE_PX = 400;
|
||||
@@ -35,15 +36,18 @@ interface SheetLayout {
|
||||
// not to show if the blade lands a fraction beside it.
|
||||
const CROP_MARK_LINE_WIDTH_PT = 0.3;
|
||||
|
||||
// '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the
|
||||
// default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD
|
||||
// on a printed label was showing.
|
||||
/**
|
||||
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
|
||||
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
|
||||
* independent of the process time zone. The label text and the label check
|
||||
* (`expected.bestBefore`) must both come from this function.
|
||||
*/
|
||||
export function labelDayOf(date: Date): string {
|
||||
return berlinDayOf(date);
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
return formatDayDe(labelDayOf(date), true);
|
||||
}
|
||||
|
||||
// Prisma stores `elements` as a plain Json column; the shape is only ever
|
||||
@@ -53,24 +57,6 @@ function elementsOf(template: LabelTemplate): LabelElement[] {
|
||||
return template.elements as unknown as LabelElement[];
|
||||
}
|
||||
|
||||
function pdfFontName(
|
||||
family: LabelFontFamily,
|
||||
bold: boolean,
|
||||
italic: boolean,
|
||||
): string {
|
||||
const base =
|
||||
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
|
||||
if (base === 'Times') {
|
||||
if (bold && italic) return 'Times-BoldItalic';
|
||||
if (bold) return 'Times-Bold';
|
||||
if (italic) return 'Times-Italic';
|
||||
return 'Times-Roman';
|
||||
}
|
||||
const suffix =
|
||||
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
interface GridFit {
|
||||
columns: number;
|
||||
rows: number;
|
||||
@@ -119,22 +105,38 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
|
||||
* grams everywhere else in the app, and honey is a food sold by mass, so a
|
||||
* label stating millilitres declares the wrong net quantity.
|
||||
*/
|
||||
export function buildPlaceholderValues(bottlingBatch: {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
date: Date;
|
||||
bestBeforeDate: Date;
|
||||
honeyBatch: { batchNumber: string; honeyType: string | null };
|
||||
product: { name: string } | null;
|
||||
}): Record<PlaceholderKey, string> {
|
||||
export function buildPlaceholderValues(
|
||||
bottlingBatch: {
|
||||
jarSize: number;
|
||||
lotNumber: string;
|
||||
date: Date;
|
||||
bestBeforeDate: Date;
|
||||
honeyBatch: { batchNumber: string; honeyType: string | null };
|
||||
product: { name: string; legalName: string | null } | null;
|
||||
},
|
||||
settings: {
|
||||
originCountry: string;
|
||||
foodBusinessName: string | null;
|
||||
foodBusinessAddress: string | null;
|
||||
},
|
||||
): Record<PlaceholderKey, string> {
|
||||
const bestBeforeDate = formatDate(bottlingBatch.bestBeforeDate);
|
||||
return {
|
||||
batchNumber: bottlingBatch.honeyBatch.batchNumber,
|
||||
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
|
||||
lotNumber: bottlingBatch.lotNumber,
|
||||
bottlingDate: formatDate(bottlingBatch.date),
|
||||
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
|
||||
bestBeforeDate,
|
||||
jarSize: `${bottlingBatch.jarSize} g`,
|
||||
productName: bottlingBatch.product?.name ?? '',
|
||||
legalName: bottlingBatch.product?.legalName ?? '',
|
||||
originCountry: settings.originCountry,
|
||||
foodBusinessName: settings.foodBusinessName ?? '',
|
||||
foodBusinessAddress: settings.foodBusinessAddress ?? '',
|
||||
bestBeforeLine: `Mindestens haltbar bis ${bestBeforeDate}`,
|
||||
lotLine: lotStartsWithL(bottlingBatch.lotNumber)
|
||||
? bottlingBatch.lotNumber
|
||||
: `L ${bottlingBatch.lotNumber}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ export class LabelTemplatesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly siteSettings: SiteSettingsService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<LabelTemplate[]> {
|
||||
@@ -189,28 +192,39 @@ export class LabelTemplatesService {
|
||||
dto: UpdateLabelTemplateDto,
|
||||
actingUserId: string,
|
||||
): Promise<LabelTemplate> {
|
||||
const before = await this.findById(id);
|
||||
const template = await this.prisma.$transaction(async (tx) => {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// "before" is read after the lock: two concurrent edits queue up and the
|
||||
// audit entry shows the state this edit really replaced.
|
||||
if (!(await lockRowForUpdate(tx, 'LabelTemplate', id))) {
|
||||
throw new NotFoundException('Label template not found');
|
||||
}
|
||||
const before = await tx.labelTemplate.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Label template not found');
|
||||
}
|
||||
if (dto.isDefault) {
|
||||
await tx.labelTemplate.updateMany({
|
||||
where: { id: { not: id } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
return tx.labelTemplate.update({
|
||||
const template = await tx.labelTemplate.update({
|
||||
where: { id },
|
||||
data: { ...dto, elements: dto.elements as never },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'LabelTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return template;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'LabelTemplate',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: template,
|
||||
});
|
||||
return template;
|
||||
}
|
||||
|
||||
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
|
||||
@@ -244,7 +258,10 @@ export class LabelTemplatesService {
|
||||
throw new NotFoundException('Bottling batch not found');
|
||||
}
|
||||
|
||||
const values = buildPlaceholderValues(bottlingBatch);
|
||||
const values = buildPlaceholderValues(
|
||||
bottlingBatch,
|
||||
await this.siteSettings.get(),
|
||||
);
|
||||
|
||||
const elements = elementsOf(template).map((element) =>
|
||||
element.type === 'text' && element.placeholderKey
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { LabelTextElement } from './label-element.types';
|
||||
import { normalizeForCheck, resolvedTexts } from './label-text';
|
||||
import { PLACEHOLDER_KEYS, PlaceholderKey } from './label-element.types';
|
||||
|
||||
function textElement(
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
overrides: Partial<LabelTextElement> = {},
|
||||
): LabelTextElement {
|
||||
return {
|
||||
id,
|
||||
type: 'text',
|
||||
x,
|
||||
y,
|
||||
width: 50,
|
||||
height: 10,
|
||||
text: id,
|
||||
fontSize: 10,
|
||||
fontFamily: 'sans',
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const values = Object.fromEntries(
|
||||
PLACEHOLDER_KEYS.map((key) => [key, `value-${key}`]),
|
||||
) as Record<PlaceholderKey, string>;
|
||||
|
||||
describe('normalizeForCheck', () => {
|
||||
it('normalizes umlauts, NFKC compatibility characters and punctuation', () => {
|
||||
expect(normalizeForCheck('Bühlweg 10, 86450 Altenmünster')).toBe(
|
||||
'bühlweg 10 86450 altenmünster',
|
||||
);
|
||||
expect(normalizeForCheck('Bühlweg 10 · 86450\nAltenmünster')).toBe(
|
||||
'bühlweg 10 86450 altenmünster',
|
||||
);
|
||||
// decomposed u + combining diaeresis equals the precomposed letter
|
||||
expect(normalizeForCheck('Bu\u0308hlweg')).toBe('bühlweg');
|
||||
// full-width digits, ligature, non-breaking space
|
||||
expect(normalizeForCheck('500\u00a0g fix')).toBe('500 g fix');
|
||||
});
|
||||
|
||||
it('splits a number glued to its unit', () => {
|
||||
expect(normalizeForCheck('250g')).toBe('250 g');
|
||||
expect(normalizeForCheck('L2026-0001')).toBe('l2026 0001');
|
||||
});
|
||||
|
||||
it('collapses runs of separators and trims', () => {
|
||||
expect(normalizeForCheck(' --L 2026-0001.. ')).toBe('l 2026 0001');
|
||||
expect(normalizeForCheck('...')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedTexts', () => {
|
||||
it('sorts elements top first, then left', () => {
|
||||
const result = resolvedTexts(
|
||||
[
|
||||
textElement('c', 30, 20),
|
||||
textElement('b', 30, 5),
|
||||
textElement('a', 5, 5),
|
||||
textElement('d', 5, 20),
|
||||
],
|
||||
values,
|
||||
);
|
||||
expect(result.map((entry) => entry.element.id)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'd',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes the placeholder value and keeps fixed text', () => {
|
||||
const result = resolvedTexts(
|
||||
[
|
||||
textElement('p', 0, 0, { placeholderKey: 'lotLine', text: 'ignored' }),
|
||||
textElement('f', 0, 10, { text: 'Honig' }),
|
||||
textElement('n', 0, 20, { placeholderKey: null, text: 'Fix' }),
|
||||
],
|
||||
values,
|
||||
);
|
||||
expect(result.map((entry) => entry.text)).toEqual([
|
||||
'value-lotLine',
|
||||
'Honig',
|
||||
'Fix',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LabelTextElement, PlaceholderKey } from './label-element.types';
|
||||
|
||||
/**
|
||||
* Text as the label check compares it: NFKC, lower case, every character that
|
||||
* is not a letter or digit becomes a space, runs of spaces collapse. So
|
||||
* "Bühlweg 10, 86450 Altenmünster" also matches "Bühlweg 10 · 86450\nAltenmünster".
|
||||
*/
|
||||
export function normalizeForCheck(text: string): string {
|
||||
return (
|
||||
text
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
// "250g" -> "250 g": a number glued to its unit
|
||||
.replace(/(\p{N})(?=\p{L})/gu, '$1 ')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// Elements whose tops are at most this far apart count as one row.
|
||||
export const ROW_TOLERANCE_MM = 1;
|
||||
|
||||
export interface ResolvedText {
|
||||
element: LabelTextElement;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text each element prints for one bottling (placeholders filled in),
|
||||
* top first, then left.
|
||||
*/
|
||||
export function resolvedTexts(
|
||||
elements: readonly LabelTextElement[],
|
||||
values: Record<PlaceholderKey, string>,
|
||||
): ResolvedText[] {
|
||||
const resolved = elements
|
||||
.map((element) => ({
|
||||
element,
|
||||
text: element.placeholderKey
|
||||
? values[element.placeholderKey]
|
||||
: element.text,
|
||||
}))
|
||||
.sort((a, b) => a.element.y - b.element.y);
|
||||
// Reading order comes from the geometry only, never from the order of the
|
||||
// elements array: rows (tops within the tolerance of the row's first top),
|
||||
// each row from left to right.
|
||||
const rows: ResolvedText[][] = [];
|
||||
let rowTop = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of resolved) {
|
||||
if (entry.element.y - rowTop > ROW_TOLERANCE_MM) {
|
||||
rows.push([]);
|
||||
rowTop = entry.element.y;
|
||||
}
|
||||
rows[rows.length - 1].push(entry);
|
||||
}
|
||||
return rows.flatMap((row) => row.sort((a, b) => a.element.x - b.element.x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the lot number already start with the lot marker "L" (as its own token
|
||||
* or glued to digits, "L-001", "L2026")? Then no further "L" is needed.
|
||||
*/
|
||||
export function lotStartsWithL(lotNumber: string): boolean {
|
||||
return /^l( |\d|$)/.test(normalizeForCheck(lotNumber));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PLACEHOLDER_KEYS } from './label-element.types';
|
||||
|
||||
describe('PLACEHOLDER_KEYS', () => {
|
||||
// The frontend compares its own list with this file.
|
||||
it('the fixture file equals PLACEHOLDER_KEYS', () => {
|
||||
const fixture: unknown = JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, '../../test/fixtures/placeholder-keys.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
expect(fixture).toEqual([...PLACEHOLDER_KEYS]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ProductKind, ShopCategory } from '../../../generated/prisma';
|
||||
@@ -26,6 +27,16 @@ export class CreateProductDto {
|
||||
@IsString()
|
||||
sku?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Verkehrsbezeichnung auf dem Etikett (muss „Honig“ enthalten), zum Beispiel „Blütenhonig“',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
legalName?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
@@ -12,10 +17,36 @@ describe('ProductsService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
product: { findUnique: jest.Mock; update: jest.Mock };
|
||||
bottlingBatch: { count: jest.Mock };
|
||||
productionBatch: { count: jest.Mock };
|
||||
};
|
||||
const order: string[] = [];
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx = {
|
||||
product: {
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve({ id: 'p1', name: 'Sommerhonig' });
|
||||
}),
|
||||
update: jest.fn().mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve({ id: 'p1', name: 'Sommerhonig 500g' });
|
||||
}),
|
||||
},
|
||||
bottlingBatch: { count: jest.fn().mockResolvedValue(0) },
|
||||
productionBatch: { count: jest.fn().mockResolvedValue(0) },
|
||||
};
|
||||
prisma = {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
@@ -23,6 +54,9 @@ describe('ProductsService', () => {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new ProductsService(
|
||||
@@ -53,8 +87,6 @@ describe('ProductsService', () => {
|
||||
it('updates a product and records before/after audit data', async () => {
|
||||
const before = { id: 'p1', name: 'Sommerhonig' };
|
||||
const after = { id: 'p1', name: 'Sommerhonig 500g' };
|
||||
prisma.product.findUnique.mockResolvedValueOnce(before);
|
||||
prisma.product.update.mockResolvedValueOnce(after);
|
||||
|
||||
const result = await service.update(
|
||||
'p1',
|
||||
@@ -69,6 +101,76 @@ describe('ProductsService', () => {
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads before after locking the Product row', async () => {
|
||||
await service.update('p1', { name: 'Sommerhonig 500g' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Product', 'p1');
|
||||
expect(order).toEqual(['lock', 'read', 'update']);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when updating a product that does not exist', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { name: 'x' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a kind change while bottlings exist', async () => {
|
||||
tx.product.findUnique.mockResolvedValueOnce({
|
||||
id: 'p1',
|
||||
kind: 'BOTTLED_HONEY',
|
||||
});
|
||||
tx.bottlingBatch.count.mockResolvedValueOnce(2);
|
||||
|
||||
await expect(
|
||||
service.update('p1', { kind: 'MANUFACTURED' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('legalName', () => {
|
||||
it('trims it on create and stores an empty one as null', async () => {
|
||||
prisma.product.create.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
await service.create({ name: 'A', legalName: ' Blütenhonig ' }, 'u');
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A', legalName: 'Blütenhonig' },
|
||||
});
|
||||
|
||||
await service.create({ name: 'A', legalName: ' ' }, 'u');
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A', legalName: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves it out of the data when the dto does not carry it', async () => {
|
||||
prisma.product.create.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
await service.create({ name: 'A' }, 'u');
|
||||
|
||||
expect(prisma.product.create).toHaveBeenLastCalledWith({
|
||||
data: { name: 'A' },
|
||||
});
|
||||
});
|
||||
|
||||
it('clears it with null on update and trims on update', async () => {
|
||||
await service.update('p1', { legalName: null }, 'u');
|
||||
expect(tx.product.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { legalName: null },
|
||||
});
|
||||
|
||||
await service.update('p1', { legalName: ' Waldhonig ' }, 'u');
|
||||
expect(tx.product.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { legalName: 'Waldhonig' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { Product, ProductStatus } from '../../generated/prisma';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
@@ -13,6 +15,17 @@ export interface ProductFilter {
|
||||
status?: ProductStatus;
|
||||
}
|
||||
|
||||
// legalName is stored trimmed; blank or null clears it. Absent stays absent so
|
||||
// a patch without it leaves the value alone.
|
||||
function withNormalizedLegalName<T extends { legalName?: string | null }>(
|
||||
dto: T,
|
||||
): T {
|
||||
if (dto.legalName === undefined) {
|
||||
return dto;
|
||||
}
|
||||
return { ...dto, legalName: dto.legalName?.trim() || null };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
@@ -36,7 +49,9 @@ export class ProductsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto, actingUserId: string): Promise<Product> {
|
||||
const product = await this.prisma.product.create({ data: { ...dto } });
|
||||
const product = await this.prisma.product.create({
|
||||
data: { ...withNormalizedLegalName(dto) },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Product',
|
||||
entityId: product.id,
|
||||
@@ -52,23 +67,34 @@ export class ProductsService {
|
||||
dto: UpdateProductDto,
|
||||
actingUserId: string,
|
||||
): Promise<Product> {
|
||||
const before = await this.findById(id);
|
||||
if (dto.kind !== undefined && dto.kind !== before.kind) {
|
||||
await this.assertKindChangeable(id);
|
||||
}
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await lockRowForUpdate(tx, 'Product', id))) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
const before = await tx.product.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
if (dto.kind !== undefined && dto.kind !== before.kind) {
|
||||
await this.assertKindChangeable(tx, id);
|
||||
}
|
||||
const product = await tx.product.update({
|
||||
where: { id },
|
||||
data: { ...withNormalizedLegalName(dto) },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Product',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: product,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return product;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Product',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: product,
|
||||
});
|
||||
return product;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +102,10 @@ export class ProductsService {
|
||||
* honey, production batches for everything else. Switching the kind while
|
||||
* batches exist would leave that stock in the wrong place.
|
||||
*/
|
||||
private async assertKindChangeable(id: string): Promise<void> {
|
||||
private async assertKindChangeable(db: DbClient, id: string): Promise<void> {
|
||||
const [bottlings, productionBatches] = await Promise.all([
|
||||
this.prisma.bottlingBatch.count({ where: { productId: id } }),
|
||||
this.prisma.productionBatch.count({ where: { productId: id } }),
|
||||
db.bottlingBatch.count({ where: { productId: id } }),
|
||||
db.productionBatch.count({ where: { productId: id } }),
|
||||
]);
|
||||
if (bottlings > 0 || productionBatches > 0) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* The catalogue of veterinary medicines starts empty (ARCHITECTURE
|
||||
* assumption 10): every value comes from the beekeeper's package leaflet or
|
||||
* prescription, the code ships no medicine or legal data.
|
||||
*/
|
||||
describe('prisma/seed.ts', () => {
|
||||
const seed = readFileSync(join(__dirname, '..', 'prisma', 'seed.ts'), 'utf8');
|
||||
|
||||
it('creates no veterinary medicine, pack or movement', () => {
|
||||
expect(seed).not.toMatch(/veterinaryMedicine/i);
|
||||
});
|
||||
|
||||
it('imports only from the known modules and has no sibling seed file', () => {
|
||||
const imported = [
|
||||
...seed.matchAll(/^import\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm),
|
||||
].map((match) => match[1]);
|
||||
expect(imported.sort()).toEqual(
|
||||
[
|
||||
'../generated/prisma',
|
||||
'@prisma/adapter-pg',
|
||||
'bcryptjs',
|
||||
'dotenv/config',
|
||||
'node:crypto',
|
||||
].sort(),
|
||||
);
|
||||
const files = readdirSync(join(__dirname, '..', 'prisma'));
|
||||
expect(files.filter((name) => name.endsWith('.ts'))).toEqual(['seed.ts']);
|
||||
});
|
||||
});
|
||||
@@ -42,19 +42,43 @@ export class UpdateSiteSettingsDto {
|
||||
@MaxLength(300)
|
||||
websiteUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Name des Bienenhalters (Bestandsbuch)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Name des Bienenhalters (Bestandsbuch)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
holderName?: string;
|
||||
holderName?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Anschrift des Bienenhalters (Bestandsbuch)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
holderAddress?: string;
|
||||
holderAddress?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Name des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
foodBusinessName?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Anschrift des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
foodBusinessAddress?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Herkunftsland auf der öffentlichen Herkunftsseite',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { SiteSettingsService } from './site-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
describe('SiteSettingsService', () => {
|
||||
let service: SiteSettingsService;
|
||||
@@ -10,12 +15,31 @@ describe('SiteSettingsService', () => {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let tx: {
|
||||
siteSettings: {
|
||||
upsert: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
const order: string[] = [];
|
||||
|
||||
function updateData(): Record<string, unknown> {
|
||||
const call = tx.siteSettings.update.mock.calls[0] as [
|
||||
{ where: { id: string }; data: Record<string, unknown> },
|
||||
];
|
||||
expect(call[0].where).toEqual({ id: 'singleton' });
|
||||
return call[0].data;
|
||||
}
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const existing = {
|
||||
id: 'singleton',
|
||||
youtubeUrl: 'https://youtube.com/@wabenhain',
|
||||
foodBusinessName: null as string | null,
|
||||
foodBusinessAddress: null as string | null,
|
||||
instagramUrl: null as string | null,
|
||||
tiktokUrl: null as string | null,
|
||||
websiteUrl: null as string | null,
|
||||
@@ -23,12 +47,33 @@ describe('SiteSettingsService', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
order.length = 0;
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx = {
|
||||
siteSettings: {
|
||||
upsert: jest.fn().mockImplementation(() => {
|
||||
order.push('upsert');
|
||||
return Promise.resolve(existing);
|
||||
}),
|
||||
findUnique: jest.fn().mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(existing);
|
||||
}),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
siteSettings: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new SiteSettingsService(
|
||||
@@ -67,9 +112,8 @@ describe('SiteSettingsService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('stores a valid link and records an audit entry', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
it('stores a valid link and records an audit entry in the transaction', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
});
|
||||
@@ -79,14 +123,9 @@ describe('SiteSettingsService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
},
|
||||
expect(updateData()).toMatchObject({
|
||||
instagramUrl: 'https://instagram.com/wabenhain',
|
||||
youtubeUrl: undefined,
|
||||
});
|
||||
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -94,44 +133,80 @@ describe('SiteSettingsService', () => {
|
||||
entityType: 'SiteSettings',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
beforeData: existing,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('update reads before after locking the SiteSettings row', async () => {
|
||||
tx.siteSettings.update.mockImplementation(() => {
|
||||
order.push('update');
|
||||
return Promise.resolve(existing);
|
||||
});
|
||||
|
||||
await service.update({ youtubeUrl: '' }, 'user-1');
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'SiteSettings',
|
||||
'singleton',
|
||||
);
|
||||
expect(order).toEqual(['upsert', 'lock', 'read', 'update']);
|
||||
});
|
||||
|
||||
it('clears a link when given an empty string', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue({
|
||||
tx.siteSettings.update.mockResolvedValue({
|
||||
...existing,
|
||||
youtubeUrl: null,
|
||||
});
|
||||
|
||||
await service.update({ youtubeUrl: '' }, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: null,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
},
|
||||
});
|
||||
expect(updateData()).toMatchObject({ youtubeUrl: null });
|
||||
});
|
||||
|
||||
it('leaves fields untouched when omitted from the dto', async () => {
|
||||
prisma.siteSettings.findUnique.mockResolvedValue(existing);
|
||||
prisma.siteSettings.update.mockResolvedValue(existing);
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update({}, 'user-1');
|
||||
|
||||
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
|
||||
where: { id: 'singleton' },
|
||||
data: {
|
||||
youtubeUrl: undefined,
|
||||
instagramUrl: undefined,
|
||||
tiktokUrl: undefined,
|
||||
websiteUrl: undefined,
|
||||
const call = tx.siteSettings.update.mock.calls[0] as [
|
||||
{ data: Record<string, unknown> },
|
||||
];
|
||||
expect(Object.values(call[0].data).every((v) => v === undefined)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('trims foodBusinessName and foodBusinessAddress and stores empty as null', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update(
|
||||
{
|
||||
foodBusinessName: ' Imkerei Muster ',
|
||||
foodBusinessAddress: ' ',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(updateData()).toMatchObject({
|
||||
foodBusinessName: 'Imkerei Muster',
|
||||
foodBusinessAddress: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the food business fields with null', async () => {
|
||||
tx.siteSettings.update.mockResolvedValue(existing);
|
||||
|
||||
await service.update(
|
||||
{ foodBusinessName: null, foodBusinessAddress: null },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(updateData()).toMatchObject({
|
||||
foodBusinessName: null,
|
||||
foodBusinessAddress: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { SiteSettings } from '../../generated/prisma';
|
||||
import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto';
|
||||
@@ -10,6 +11,17 @@ function emptyToNull(value: string | undefined): string | null {
|
||||
return value === '' ? null : (value ?? null);
|
||||
}
|
||||
|
||||
// undefined keeps the stored value, null or blank clears it, anything else is
|
||||
// stored trimmed.
|
||||
function trimmedOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SiteSettingsService {
|
||||
constructor(
|
||||
@@ -34,43 +46,56 @@ export class SiteSettingsService {
|
||||
dto: UpdateSiteSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<SiteSettings> {
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.siteSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
youtubeUrl:
|
||||
dto.youtubeUrl !== undefined
|
||||
? emptyToNull(dto.youtubeUrl)
|
||||
: undefined,
|
||||
instagramUrl:
|
||||
dto.instagramUrl !== undefined
|
||||
? emptyToNull(dto.instagramUrl)
|
||||
: undefined,
|
||||
tiktokUrl:
|
||||
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
|
||||
websiteUrl:
|
||||
dto.websiteUrl !== undefined
|
||||
? emptyToNull(dto.websiteUrl)
|
||||
: undefined,
|
||||
holderName:
|
||||
dto.holderName !== undefined
|
||||
? emptyToNull(dto.holderName.trim())
|
||||
: undefined,
|
||||
holderAddress:
|
||||
dto.holderAddress !== undefined
|
||||
? emptyToNull(dto.holderAddress.trim())
|
||||
: undefined,
|
||||
originCountry: dto.originCountry?.trim(),
|
||||
},
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The singleton row must exist to be locked; "before" is read after the
|
||||
// lock so concurrent edits queue up instead of overwriting each other.
|
||||
await tx.siteSettings.upsert({
|
||||
where: { id: SINGLETON_ID },
|
||||
update: {},
|
||||
create: { id: SINGLETON_ID },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'SiteSettings', SINGLETON_ID);
|
||||
const before = await tx.siteSettings.findUnique({
|
||||
where: { id: SINGLETON_ID },
|
||||
});
|
||||
const settings = await tx.siteSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
data: {
|
||||
youtubeUrl:
|
||||
dto.youtubeUrl !== undefined
|
||||
? emptyToNull(dto.youtubeUrl)
|
||||
: undefined,
|
||||
instagramUrl:
|
||||
dto.instagramUrl !== undefined
|
||||
? emptyToNull(dto.instagramUrl)
|
||||
: undefined,
|
||||
tiktokUrl:
|
||||
dto.tiktokUrl !== undefined
|
||||
? emptyToNull(dto.tiktokUrl)
|
||||
: undefined,
|
||||
websiteUrl:
|
||||
dto.websiteUrl !== undefined
|
||||
? emptyToNull(dto.websiteUrl)
|
||||
: undefined,
|
||||
holderName: trimmedOrNull(dto.holderName),
|
||||
holderAddress: trimmedOrNull(dto.holderAddress),
|
||||
foodBusinessName: trimmedOrNull(dto.foodBusinessName),
|
||||
foodBusinessAddress: trimmedOrNull(dto.foodBusinessAddress),
|
||||
originCountry: dto.originCountry?.trim(),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'SiteSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return settings;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'SiteSettings',
|
||||
entityId: SINGLETON_ID,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: settings,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MAX_ADDRESS_LENGTH,
|
||||
MAX_NAME_LENGTH,
|
||||
MAX_NOTE_LENGTH,
|
||||
} from './create-veterinary-medicine.dto';
|
||||
|
||||
export const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||
|
||||
/** One bought pack. Batch number, supplier, proof and vet apply to every treatment from it. */
|
||||
export class CreateLotDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
medicineId!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Ch.-B.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
batchNumber?: string | null;
|
||||
|
||||
@ApiProperty({ example: '2026-08-01' })
|
||||
@Matches(DAY_PATTERN, { message: `purchasedOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
purchasedOn!: string;
|
||||
|
||||
@ApiProperty({ description: 'Anfangsmenge in der Einheit des Mittels, > 0' })
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@IsPositive()
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '2028-01-31' })
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `expiresOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
expiresOn?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Name, Anschrift' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
supplier?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'z. B. Rechnung 4711' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
purchaseProof?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
prescribingVet?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MANUAL_MOVEMENT_TYPES = ['CORRECTION', 'DISPOSAL'] as const;
|
||||
export type ManualMovementType = (typeof MANUAL_MOVEMENT_TYPES)[number];
|
||||
export const MAX_REASON_LENGTH = 500;
|
||||
const FINITE = { allowNaN: false, allowInfinity: false } as const;
|
||||
|
||||
/**
|
||||
* CORRECTION (inventory): the rest counted in the box. DISPOSAL: a positive
|
||||
* quantity that leaves the lot, with a reason. TREATMENT bookings come only
|
||||
* from a treatment.
|
||||
*/
|
||||
export class CreateMovementDto {
|
||||
@ApiProperty({ enum: MANUAL_MOVEMENT_TYPES })
|
||||
@IsIn(MANUAL_MOVEMENT_TYPES)
|
||||
type!: ManualMovementType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'CORRECTION: der tatsächliche Rest' })
|
||||
@ValidateIf((dto: CreateMovementDto) => dto.type === 'CORRECTION')
|
||||
@IsNumber(FINITE)
|
||||
@Min(0)
|
||||
actualRemaining?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'DISPOSAL: Menge, positiv' })
|
||||
@ValidateIf((dto: CreateMovementDto) => dto.type === 'DISPOSAL')
|
||||
@IsNumber(FINITE)
|
||||
@IsPositive()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'DISPOSAL: Pflicht' })
|
||||
@ValidateIf(
|
||||
(dto: CreateMovementDto) => dto.type === 'DISPOSAL' || dto.reason != null,
|
||||
)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'reason darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_REASON_LENGTH)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { MedicineUnit } from '../../../generated/prisma';
|
||||
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||
|
||||
export const MAX_NAME_LENGTH = 200;
|
||||
export const MAX_ADDRESS_LENGTH = 300;
|
||||
export const MAX_NOTE_LENGTH = 2000;
|
||||
const MAX_DOSE_NOTE_LENGTH = 500;
|
||||
const FINITE = { allowNaN: false, allowInfinity: false } as const;
|
||||
|
||||
/**
|
||||
* Every value comes from the package leaflet or the prescription and is typed
|
||||
* in by the beekeeper - the code ships no medicine data (assumption 10).
|
||||
*/
|
||||
export class CreateVeterinaryMedicineDto {
|
||||
@ApiProperty({ description: 'Handelsname', maxLength: MAX_NAME_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'name darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: MedicineUnit,
|
||||
description: 'Nur änderbar, solange keine Packung existiert.',
|
||||
})
|
||||
@IsEnum(MedicineUnit)
|
||||
unit!: MedicineUnit;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
activeIngredient?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Zulassungsnummer' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
authorisationNumber?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptionalNotNull()
|
||||
@IsBoolean()
|
||||
prescriptionOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
description: 'Wartezeit Honig in Tagen, auch 0',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
withdrawalPeriodDays?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'In der Einheit' })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
@IsPositive()
|
||||
dosePerColony?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_DOSE_NOTE_LENGTH)
|
||||
doseNote?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NAME_LENGTH)
|
||||
applicationMethod?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
minTemperatureC?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumber(FINITE)
|
||||
maxTemperatureC?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ADDRESS_LENGTH)
|
||||
defaultSupplier?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ID_PATTERN } from './create-lot.dto';
|
||||
import { booleanQuery } from './medicine-list-query.dto';
|
||||
|
||||
export class LotListQuery {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(64)
|
||||
medicineId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nur Packungen mit Rest > 0, älteste Haltbarkeit zuerst',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
selectable?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Mit selectable: diese Packung bleibt wählbar, auch wenn sie leer oder ihr Mittel inaktiv ist (Bearbeiten einer Behandlung)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(ID_PATTERN)
|
||||
@MaxLength(64)
|
||||
includeLotId?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
/** Query strings arrive as text: only "true" and "false" are booleans. */
|
||||
export function booleanQuery({ value }: TransformFnParams): unknown {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value as unknown;
|
||||
}
|
||||
|
||||
export class MedicineListQuery {
|
||||
@ApiPropertyOptional({ description: 'Nur aktive oder nur inaktive Mittel' })
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateLotDto } from './create-lot.dto';
|
||||
|
||||
/** `medicineId` is fixed once a movement exists (409, checked in the service). */
|
||||
export class UpdateLotDto extends PartialType(CreateLotDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateVeterinaryMedicineDto } from './create-veterinary-medicine.dto';
|
||||
|
||||
/** Omitted stays, null clears a nullable field, null on a required one is a 400. */
|
||||
export class UpdateVeterinaryMedicineDto extends PartialType(
|
||||
CreateVeterinaryMedicineDto,
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { summarizeLot, treatmentBookingDelta } from './medicine-ledger';
|
||||
|
||||
describe('summarizeLot', () => {
|
||||
it('rounds to thousandths', () => {
|
||||
expect(
|
||||
summarizeLot(10, [{ quantity: -3.3333 }, { quantity: -3.3333 }])
|
||||
.remaining,
|
||||
).toBe(3.333);
|
||||
});
|
||||
|
||||
it('reports what was used', () => {
|
||||
expect(summarizeLot(10, [{ quantity: -4 }, { quantity: 1 }])).toEqual({
|
||||
remaining: 7,
|
||||
used: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the rest go negative', () => {
|
||||
expect(summarizeLot(1, [{ quantity: -2.5 }]).remaining).toBe(-1.5);
|
||||
});
|
||||
|
||||
it('is the initial quantity without movements', () => {
|
||||
expect(summarizeLot(5, [])).toEqual({ remaining: 5, used: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('treatmentBookingDelta', () => {
|
||||
it('books the full dose on create', () => {
|
||||
expect(treatmentBookingDelta([], { lotId: 'A', dose: 5 })).toEqual([
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves the booking when the lot changes', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'B',
|
||||
dose: 5,
|
||||
}),
|
||||
).toEqual([
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
{ lotId: 'B', quantity: -5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reverses the booking when dose or lot is cleared', () => {
|
||||
expect(treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], null)).toEqual(
|
||||
[{ lotId: 'A', quantity: 5 }],
|
||||
);
|
||||
});
|
||||
|
||||
it('books nothing when nothing changed', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'A',
|
||||
dose: 5,
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(treatmentBookingDelta([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('books only the difference when the dose changes on the same lot', () => {
|
||||
expect(
|
||||
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
|
||||
lotId: 'A',
|
||||
dose: 7,
|
||||
}),
|
||||
).toEqual([{ lotId: 'A', quantity: -2 }]);
|
||||
});
|
||||
|
||||
it('sums several earlier bookings of one lot and rounds the difference', () => {
|
||||
expect(
|
||||
treatmentBookingDelta(
|
||||
[
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
{ lotId: 'A', quantity: -0.1 },
|
||||
],
|
||||
{ lotId: 'A', dose: 0.3 },
|
||||
),
|
||||
).toEqual([{ lotId: 'A', quantity: -0.2 }]);
|
||||
});
|
||||
|
||||
it('counts an earlier dose-reduction booking', () => {
|
||||
// dose 5 booked, later reduced to 3 (+2): the lot carries -3 in total
|
||||
const current = [
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 2 },
|
||||
];
|
||||
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 3 })).toEqual([]);
|
||||
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 4 })).toEqual([
|
||||
{ lotId: 'A', quantity: -1 },
|
||||
]);
|
||||
expect(treatmentBookingDelta(current, null)).toEqual([
|
||||
{ lotId: 'A', quantity: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a fully reversed lot as nothing to do', () => {
|
||||
expect(
|
||||
treatmentBookingDelta(
|
||||
[
|
||||
{ lotId: 'A', quantity: -5 },
|
||||
{ lotId: 'A', quantity: 5 },
|
||||
],
|
||||
null,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure arithmetic of the veterinary medicine book - no database, so the rules
|
||||
* that decide what is booked can be tested on their own.
|
||||
*
|
||||
* Rest of a lot = its initial quantity + the sum of its signed movements,
|
||||
* rounded to thousandths. It is always derived, never stored, so it cannot
|
||||
* drift from the book. A negative rest is allowed: the treatment happened,
|
||||
* refusing it would only hide the gap.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MedicineUnit,
|
||||
VeterinaryMedicine,
|
||||
VeterinaryMedicineMovementType,
|
||||
} from '../../generated/prisma';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
|
||||
export interface LotLedgerEntry {
|
||||
/** Signed: negative takes medicine out of the lot. */
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export function summarizeLot(
|
||||
initial: number,
|
||||
entries: readonly LotLedgerEntry[],
|
||||
): { remaining: number; used: number } {
|
||||
const remaining = roundToThousandths(
|
||||
entries.reduce((sum, entry) => sum + entry.quantity, initial),
|
||||
);
|
||||
return { remaining, used: roundToThousandths(initial - remaining) };
|
||||
}
|
||||
|
||||
/** The signed sum of the TREATMENT bookings made so far on one lot. */
|
||||
export interface BookedPerLot {
|
||||
lotId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface DesiredBooking {
|
||||
lotId: string;
|
||||
/** > 0: what the treatment takes out of the lot. */
|
||||
dose: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to book so that the treatment's bookings add up to the wanted state:
|
||||
* a counter-booking on every lot that carries too much or too little, and
|
||||
* -dose on the wanted lot. Nothing to do yields an empty list. Bookings are
|
||||
* never changed or deleted, a correction is a new booking.
|
||||
*/
|
||||
export function treatmentBookingDelta(
|
||||
current: readonly BookedPerLot[],
|
||||
desired: DesiredBooking | null,
|
||||
): BookedPerLot[] {
|
||||
const bookedByLot = new Map<string, number>();
|
||||
for (const booking of current) {
|
||||
bookedByLot.set(
|
||||
booking.lotId,
|
||||
(bookedByLot.get(booking.lotId) ?? 0) + booking.quantity,
|
||||
);
|
||||
}
|
||||
if (desired !== null && !bookedByLot.has(desired.lotId)) {
|
||||
bookedByLot.set(desired.lotId, 0);
|
||||
}
|
||||
const deltas: BookedPerLot[] = [];
|
||||
for (const [lotId, booked] of bookedByLot) {
|
||||
const target = desired?.lotId === lotId ? -desired.dose : 0;
|
||||
const quantity = roundToThousandths(target - booked);
|
||||
if (quantity !== 0) {
|
||||
deltas.push({ lotId, quantity });
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/** Dates without a time of day travel as YYYY-MM-DD. */
|
||||
export type MedicineView = VeterinaryMedicine & {
|
||||
/** Σ rest of all lots of the medicine (a lot below zero counts negative). */
|
||||
totalRemaining: number;
|
||||
/** Earliest expiry of a lot that still has a rest, or null. */
|
||||
nextExpiry: string | null;
|
||||
lotCount: number;
|
||||
};
|
||||
|
||||
export interface LotView {
|
||||
id: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
unit: MedicineUnit;
|
||||
batchNumber: string | null;
|
||||
purchasedOn: string;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
expiresOn: string | null;
|
||||
supplier: string | null;
|
||||
purchaseProof: string | null;
|
||||
prescribingVet: string | null;
|
||||
notes: string | null;
|
||||
movementCount: number;
|
||||
}
|
||||
|
||||
export interface MovementView {
|
||||
id: string;
|
||||
type: VeterinaryMedicineMovementType;
|
||||
quantity: number;
|
||||
treatmentId: string | null;
|
||||
reason: string | null;
|
||||
userId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LotDetailView extends LotView {
|
||||
movements: MovementView[];
|
||||
}
|
||||
|
||||
export interface MedicineSuggestion {
|
||||
name: string;
|
||||
treatmentCount: number;
|
||||
latest: {
|
||||
activeIngredient: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
method: string | null;
|
||||
amount: string | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
MedicineUnit,
|
||||
VeterinaryMedicine,
|
||||
VeterinaryMedicineLot,
|
||||
} from '../../generated/prisma';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import {
|
||||
summarizeLot,
|
||||
type LotView,
|
||||
type MedicineView,
|
||||
} from './medicine-ledger';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
|
||||
/** Σ and number of the movements of one lot. */
|
||||
export interface LotTotals {
|
||||
sum: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const NO_MOVEMENTS: LotTotals = { sum: 0, count: 0 };
|
||||
|
||||
/** The medicine a lot view names. */
|
||||
export const LOT_MEDICINE_INCLUDE = {
|
||||
medicine: { select: { name: true, unit: true } },
|
||||
} as const;
|
||||
|
||||
export type LotRow = VeterinaryMedicineLot & {
|
||||
medicine: { name: string; unit: MedicineUnit };
|
||||
};
|
||||
|
||||
/** One grouped query for all lots, never one per lot. */
|
||||
export async function lotTotals(
|
||||
db: DbClient,
|
||||
lotIds: readonly string[],
|
||||
): Promise<Map<string, LotTotals>> {
|
||||
if (lotIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows = await db.veterinaryMedicineMovement.groupBy({
|
||||
by: ['lotId'],
|
||||
where: { lotId: { in: [...lotIds] } },
|
||||
_sum: { quantity: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.lotId,
|
||||
{ sum: row._sum.quantity ?? 0, count: row._count._all },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export const dayText = (date: Date): string => date.toISOString().slice(0, 10);
|
||||
|
||||
export function remainingOf(
|
||||
lot: { quantity: number },
|
||||
totals: LotTotals | undefined,
|
||||
): number {
|
||||
return summarizeLot(lot.quantity, [
|
||||
{ quantity: (totals ?? NO_MOVEMENTS).sum },
|
||||
]).remaining;
|
||||
}
|
||||
|
||||
export function toLotView(lot: LotRow, totals: LotTotals | undefined): LotView {
|
||||
return {
|
||||
id: lot.id,
|
||||
medicineId: lot.medicineId,
|
||||
medicineName: lot.medicine.name,
|
||||
unit: lot.medicine.unit,
|
||||
batchNumber: lot.batchNumber,
|
||||
purchasedOn: dayText(lot.purchasedOn),
|
||||
quantity: lot.quantity,
|
||||
remaining: remainingOf(lot, totals),
|
||||
expiresOn: lot.expiresOn === null ? null : dayText(lot.expiresOn),
|
||||
supplier: lot.supplier,
|
||||
purchaseProof: lot.purchaseProof,
|
||||
prescribingVet: lot.prescribingVet,
|
||||
notes: lot.notes,
|
||||
movementCount: (totals ?? NO_MOVEMENTS).count,
|
||||
};
|
||||
}
|
||||
|
||||
export function toMedicineView(
|
||||
medicine: VeterinaryMedicine,
|
||||
lots: readonly Pick<VeterinaryMedicineLot, 'id' | 'quantity' | 'expiresOn'>[],
|
||||
totals: ReadonlyMap<string, LotTotals>,
|
||||
): MedicineView {
|
||||
let total = 0;
|
||||
let nextExpiry: Date | null = null;
|
||||
for (const lot of lots) {
|
||||
const remaining = remainingOf(lot, totals.get(lot.id));
|
||||
total += remaining;
|
||||
if (
|
||||
remaining > 0 &&
|
||||
lot.expiresOn !== null &&
|
||||
(nextExpiry === null || lot.expiresOn < nextExpiry)
|
||||
) {
|
||||
nextExpiry = lot.expiresOn;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...medicine,
|
||||
totalRemaining: roundToThousandths(total),
|
||||
nextExpiry: nextExpiry === null ? null : dayText(nextExpiry),
|
||||
lotCount: lots.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreateLotDto } from './dto/create-lot.dto';
|
||||
import { CreateMovementDto } from './dto/create-movement.dto';
|
||||
import { LotListQuery } from './dto/lot-list-query.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
|
||||
// There is deliberately no route to change or delete a movement: the book is
|
||||
// append-only, a mistake is booked back.
|
||||
@ApiTags('veterinary-medicines')
|
||||
@ApiBearerAuth()
|
||||
@Controller('veterinary-medicine-lots')
|
||||
export class VeterinaryMedicineLotsController {
|
||||
constructor(private readonly lots: VeterinaryMedicineLotsService) {}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get()
|
||||
findAll(@Query() query: LotListQuery) {
|
||||
return this.lots.findAll(query);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.lots.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(@Body() dto: CreateLotDto, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.lots.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLotDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.lots.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Idempotent()
|
||||
@Post(':id/movements')
|
||||
addMovement(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CreateMovementDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.lots.addMovement(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const lot = {
|
||||
id: 'l1',
|
||||
medicineId: 'm1',
|
||||
batchNumber: 'CH-1',
|
||||
purchasedOn: new Date('2026-08-01T00:00:00Z'),
|
||||
quantity: 10,
|
||||
expiresOn: null,
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
prescribingVet: null,
|
||||
notes: null,
|
||||
userId: 'u1',
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
medicine: { name: 'Mittel A', unit: 'ML' },
|
||||
};
|
||||
|
||||
function lotAt(id: string, quantity: number, expiresOn: string | null) {
|
||||
return {
|
||||
...lot,
|
||||
id,
|
||||
quantity,
|
||||
expiresOn: expiresOn === null ? null : new Date(`${expiresOn}T00:00:00Z`),
|
||||
};
|
||||
}
|
||||
|
||||
describe('VeterinaryMedicineLotsService', () => {
|
||||
let service: VeterinaryMedicineLotsService;
|
||||
let tx: {
|
||||
veterinaryMedicine: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
veterinaryMedicineMovement: {
|
||||
aggregate: jest.Mock;
|
||||
count: jest.Mock;
|
||||
create: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
groupBy: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
veterinaryMedicineLot: { findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
(lockRowsForUpdate as jest.Mock).mockResolvedValue(undefined);
|
||||
tx = {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'm1', unit: 'ML', active: true }),
|
||||
},
|
||||
veterinaryMedicineLot: {
|
||||
findUnique: jest.fn().mockResolvedValue(lot),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve({ ...lot, ...data }),
|
||||
),
|
||||
create: jest.fn().mockResolvedValue(lot),
|
||||
},
|
||||
veterinaryMedicineMovement: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: -3 } }),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
veterinaryMedicineLot: { findMany: jest.fn() },
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new VeterinaryMedicineLotsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('addMovement', () => {
|
||||
it('CORRECTION books the delta between the rest and the actual rest', async () => {
|
||||
// rest 10 - 3 = 7, counted 5
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 5 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
lotId: 'l1',
|
||||
type: 'CORRECTION',
|
||||
quantity: -2,
|
||||
reason: null,
|
||||
userId: 'u1',
|
||||
},
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'VeterinaryMedicineLot',
|
||||
entityId: 'l1',
|
||||
action: 'UPDATE',
|
||||
beforeData: expect.objectContaining({ remaining: 7 }) as object,
|
||||
afterData: expect.objectContaining({ remaining: 5 }) as object,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('CORRECTION books a positive delta when more is found', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 8.5 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ quantity: 1.5 }) as object,
|
||||
});
|
||||
});
|
||||
|
||||
it('CORRECTION books nothing for delta 0', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 7 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the lot before it reads the rest', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx.veterinaryMedicineMovement.aggregate.mockImplementation(() => {
|
||||
order.push('sum');
|
||||
return Promise.resolve({ _sum: { quantity: 0 } });
|
||||
});
|
||||
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'CORRECTION', actualRemaining: 1 },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(order).toEqual(['lock', 'sum']);
|
||||
});
|
||||
|
||||
it('DISPOSAL books the negative quantity and needs a reason', async () => {
|
||||
await service.addMovement(
|
||||
'l1',
|
||||
{ type: 'DISPOSAL', quantity: 2, reason: 'abgelaufen' },
|
||||
'u1',
|
||||
);
|
||||
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
lotId: 'l1',
|
||||
type: 'DISPOSAL',
|
||||
quantity: -2,
|
||||
reason: 'abgelaufen',
|
||||
userId: 'u1',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addMovement('l1', { type: 'DISPOSAL', quantity: 2 }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown lot', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.addMovement(
|
||||
'x',
|
||||
{ type: 'CORRECTION', actualRemaining: 1 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('rejects changing medicineId once a movement exists with 409', async () => {
|
||||
tx.veterinaryMedicineMovement.count.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the medicines before the lot', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowsForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('medicines');
|
||||
return Promise.resolve();
|
||||
});
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lot');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
|
||||
await service.update('l1', { medicineId: 'm2' }, 'u1');
|
||||
|
||||
expect(order).toEqual(['medicines', 'lot']);
|
||||
});
|
||||
|
||||
it('rejects a move to a medicine with another unit with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm2',
|
||||
unit: 'G',
|
||||
active: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toThrow('Die Einheit des Mittels passt nicht zur Packung.');
|
||||
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a move to an inactive medicine with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm2',
|
||||
unit: 'ML',
|
||||
active: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('l1', { medicineId: 'm2' }, 'u1'),
|
||||
).rejects.toThrow('Das Mittel ist deaktiviert.');
|
||||
});
|
||||
|
||||
it('allows the other fields with movements', async () => {
|
||||
tx.veterinaryMedicineMovement.count.mockResolvedValue(5);
|
||||
|
||||
await service.update(
|
||||
'l1',
|
||||
{ supplier: 'Imkerei Muster', notes: null },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(tx.veterinaryMedicineLot.update).toHaveBeenCalledWith({
|
||||
where: { id: 'l1' },
|
||||
data: { supplier: 'Imkerei Muster', notes: null },
|
||||
include: { medicine: { select: { name: true, unit: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('rejects a lot for an inactive medicine with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
id: 'm1',
|
||||
unit: 'ML',
|
||||
active: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ medicineId: 'm1', purchasedOn: '2026-08-01', quantity: 10 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toThrow('Das Mittel ist deaktiviert.');
|
||||
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the medicine and answers 404 for an unknown one', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{ medicineId: 'x', purchasedOn: '2026-08-01', quantity: 10 },
|
||||
'u1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
beforeEach(() => {
|
||||
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
lotAt('late', 10, '2027-06-01'),
|
||||
lotAt('empty', 5, '2026-01-01'),
|
||||
lotAt('early', 10, '2026-09-01'),
|
||||
lotAt('none', 10, null),
|
||||
]);
|
||||
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||
{ lotId: 'empty', _sum: { quantity: -5 }, _count: { _all: 1 } },
|
||||
{ lotId: 'early', _sum: { quantity: -12 }, _count: { _all: 2 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('selectable lots are ordered oldest expiry first and exclude an empty lot', async () => {
|
||||
const lots = await service.findAll({ selectable: true });
|
||||
|
||||
// 'empty' (rest 0) and 'early' (rest -2) have nothing left to take
|
||||
expect(lots.map((entry) => entry.id)).toEqual(['late', 'none']);
|
||||
});
|
||||
|
||||
it('asks only for lots of active medicines when selectable, plus includeLotId', async () => {
|
||||
await service.findAll({ selectable: true, includeLotId: 'keep' });
|
||||
|
||||
expect(prisma.veterinaryMedicineLot.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
medicineId: undefined,
|
||||
OR: [{ medicine: { active: true } }, { id: 'keep' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the includeLotId lot although it is empty', async () => {
|
||||
const lots = await service.findAll({
|
||||
selectable: true,
|
||||
includeLotId: 'empty',
|
||||
});
|
||||
|
||||
expect(lots.map((entry) => entry.id)).toEqual(['empty', 'late', 'none']);
|
||||
});
|
||||
|
||||
it('a negative rest is allowed and reported in the view', async () => {
|
||||
const lots = await service.findAll({});
|
||||
|
||||
expect(lots.find((entry) => entry.id === 'early')).toMatchObject({
|
||||
remaining: -2,
|
||||
movementCount: 2,
|
||||
});
|
||||
expect(lots).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, type MedicineUnit } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { dayToDate } from '../common/dates';
|
||||
import { roundToThousandths } from '../common/quantity';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { fifoOrder } from '../stock/stock-ledger';
|
||||
import { CreateLotDto } from './dto/create-lot.dto';
|
||||
import { CreateMovementDto } from './dto/create-movement.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
import {
|
||||
summarizeLot,
|
||||
type LotDetailView,
|
||||
type LotView,
|
||||
} from './medicine-ledger';
|
||||
import {
|
||||
LOT_MEDICINE_INCLUDE,
|
||||
lotTotals,
|
||||
remainingOf,
|
||||
toLotView,
|
||||
type LotRow,
|
||||
} from './medicine-views';
|
||||
|
||||
const AUDIT_ENTITY = 'VeterinaryMedicineLot';
|
||||
const MEDICINE_NOT_FOUND = 'Tierarzneimittel nicht gefunden';
|
||||
const LOT_NOT_FOUND = 'Packung nicht gefunden';
|
||||
|
||||
export interface LotListFilter {
|
||||
medicineId?: string;
|
||||
selectable?: boolean;
|
||||
/** With `selectable`: this lot stays in the list even when empty or inactive. */
|
||||
includeLotId?: string;
|
||||
}
|
||||
|
||||
/** The lot row as audited: without the medicine the view joins in. */
|
||||
function auditRow(lot: LotRow, remaining?: number): object {
|
||||
const { id, medicineId, batchNumber, purchasedOn, quantity, expiresOn } = lot;
|
||||
const row = {
|
||||
id,
|
||||
medicineId,
|
||||
batchNumber,
|
||||
purchasedOn,
|
||||
quantity,
|
||||
expiresOn,
|
||||
supplier: lot.supplier,
|
||||
purchaseProof: lot.purchaseProof,
|
||||
prescribingVet: lot.prescribingVet,
|
||||
notes: lot.notes,
|
||||
userId: lot.userId,
|
||||
createdAt: lot.createdAt,
|
||||
updatedAt: lot.updatedAt,
|
||||
};
|
||||
return remaining === undefined ? row : { ...row, remaining };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VeterinaryMedicineLotsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Newest purchase first. `selectable`: lots of active medicines with a rest
|
||||
* > 0, oldest expiry first (FIFO); `includeLotId` (the lot of a treatment
|
||||
* being edited) stays selectable even when empty or its medicine is inactive.
|
||||
*/
|
||||
async findAll(filter: LotListFilter): Promise<LotView[]> {
|
||||
const lots = await this.prisma.veterinaryMedicineLot.findMany({
|
||||
where: {
|
||||
medicineId: filter.medicineId,
|
||||
...(filter.selectable && {
|
||||
OR: [
|
||||
{ medicine: { active: true } },
|
||||
...(filter.includeLotId ? [{ id: filter.includeLotId }] : []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
this.prisma,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
const entries = lots.map((lot) => ({
|
||||
id: lot.id,
|
||||
remaining: remainingOf(lot, totals.get(lot.id)),
|
||||
bestBeforeDate: lot.expiresOn,
|
||||
date: lot.purchasedOn,
|
||||
view: toLotView(lot, totals.get(lot.id)),
|
||||
}));
|
||||
const chosen = filter.selectable
|
||||
? fifoOrder(
|
||||
entries.filter(
|
||||
(entry) => entry.remaining > 0 || entry.id === filter.includeLotId,
|
||||
),
|
||||
)
|
||||
: entries;
|
||||
return chosen.map((entry) => entry.view);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<LotDetailView> {
|
||||
return this.detail(this.prisma, id);
|
||||
}
|
||||
|
||||
async create(dto: CreateLotDto, actingUserId: string): Promise<LotView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// The medicine first: changing its unit counts the lots under this lock.
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', dto.medicineId))) {
|
||||
throw new NotFoundException(MEDICINE_NOT_FOUND);
|
||||
}
|
||||
await this.assertMedicineActive(tx, dto.medicineId);
|
||||
const created = await tx.veterinaryMedicineLot.create({
|
||||
data: {
|
||||
...dto,
|
||||
purchasedOn: dayToDate(dto.purchasedOn),
|
||||
expiresOn:
|
||||
dto.expiresOn == null ? dto.expiresOn : dayToDate(dto.expiresOn),
|
||||
userId: actingUserId,
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: auditRow(created),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return toLotView(created, undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The medicine of a lot is fixed once a movement exists (409). Lock order:
|
||||
* medicines (ascending), then the lot - the order of the lock map.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateLotDto,
|
||||
actingUserId: string,
|
||||
): Promise<LotView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const readMedicineId = await this.lockMedicinesThenLot(tx, id, dto);
|
||||
const before = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!before) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
if (
|
||||
dto.medicineId !== undefined &&
|
||||
dto.medicineId !== before.medicineId
|
||||
) {
|
||||
await this.assertMedicineChangeable(tx, before, readMedicineId, dto);
|
||||
}
|
||||
const { purchasedOn, expiresOn, ...rest } = dto;
|
||||
const updated = await tx.veterinaryMedicineLot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(purchasedOn !== undefined && {
|
||||
purchasedOn: dayToDate(purchasedOn),
|
||||
}),
|
||||
...(expiresOn !== undefined && {
|
||||
expiresOn: expiresOn === null ? null : dayToDate(expiresOn),
|
||||
}),
|
||||
},
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: auditRow(before),
|
||||
afterData: auditRow(updated),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
const totals = await lotTotals(tx, [id]);
|
||||
return toLotView(updated, totals.get(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CORRECTION books the difference between the rest in the book and the rest
|
||||
* counted (nothing for a difference of 0); DISPOSAL books the quantity as a
|
||||
* negative one. The rest is read under the lot lock, so two inventories at
|
||||
* the same time are applied one after the other.
|
||||
*/
|
||||
async addMovement(
|
||||
id: string,
|
||||
dto: CreateMovementDto,
|
||||
actingUserId: string,
|
||||
): Promise<LotDetailView> {
|
||||
const manual = this.manualBooking(dto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const lot = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!lot) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const booked = await tx.veterinaryMedicineMovement.aggregate({
|
||||
where: { lotId: id },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const { remaining } = summarizeLot(lot.quantity, [
|
||||
{ quantity: booked._sum.quantity ?? 0 },
|
||||
]);
|
||||
const quantity =
|
||||
manual.type === 'CORRECTION'
|
||||
? roundToThousandths(manual.actualRemaining - remaining)
|
||||
: roundToThousandths(-manual.quantity);
|
||||
if (quantity === 0) {
|
||||
if (manual.type === 'DISPOSAL') {
|
||||
throw new BadRequestException('Die Menge ist zu klein');
|
||||
}
|
||||
return this.detail(tx, id);
|
||||
}
|
||||
await tx.veterinaryMedicineMovement.create({
|
||||
data: {
|
||||
lotId: id,
|
||||
type: manual.type,
|
||||
quantity,
|
||||
reason: manual.reason,
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: auditRow(lot, remaining),
|
||||
afterData: auditRow(lot, roundToThousandths(remaining + quantity)),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return this.detail(tx, id);
|
||||
});
|
||||
}
|
||||
|
||||
private manualBooking(
|
||||
dto: CreateMovementDto,
|
||||
):
|
||||
| { type: 'CORRECTION'; actualRemaining: number; reason: string | null }
|
||||
| { type: 'DISPOSAL'; quantity: number; reason: string } {
|
||||
if (dto.type === 'CORRECTION') {
|
||||
if (dto.actualRemaining === undefined || !(dto.actualRemaining >= 0)) {
|
||||
throw new BadRequestException('Der tatsächliche Rest fehlt');
|
||||
}
|
||||
return {
|
||||
type: 'CORRECTION',
|
||||
actualRemaining: dto.actualRemaining,
|
||||
reason: dto.reason ?? null,
|
||||
};
|
||||
}
|
||||
if (dto.quantity === undefined || !(dto.quantity > 0)) {
|
||||
throw new BadRequestException('Die Menge muss größer als 0 sein');
|
||||
}
|
||||
if (dto.reason === undefined || dto.reason.trim() === '') {
|
||||
throw new BadRequestException('Für eine Entsorgung ist ein Grund nötig');
|
||||
}
|
||||
return { type: 'DISPOSAL', quantity: dto.quantity, reason: dto.reason };
|
||||
}
|
||||
|
||||
/** Returns the medicine id read before the locks, to detect a concurrent change. */
|
||||
private async lockMedicinesThenLot(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
dto: UpdateLotDto,
|
||||
): Promise<string | null> {
|
||||
let readMedicineId: string | null = null;
|
||||
if (dto.medicineId !== undefined) {
|
||||
const current = await tx.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
select: { medicineId: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
readMedicineId = current.medicineId;
|
||||
await lockRowsForUpdate(tx, 'VeterinaryMedicine', [
|
||||
current.medicineId,
|
||||
dto.medicineId,
|
||||
]);
|
||||
}
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
return readMedicineId;
|
||||
}
|
||||
|
||||
/** Read under the medicine lock: an inactive medicine takes no new stock. */
|
||||
private async assertMedicineActive(
|
||||
tx: Prisma.TransactionClient,
|
||||
medicineId: string | undefined,
|
||||
): Promise<{ unit: MedicineUnit }> {
|
||||
const medicine = await tx.veterinaryMedicine.findUnique({
|
||||
where: { id: medicineId },
|
||||
select: { unit: true, active: true },
|
||||
});
|
||||
if (!medicine) {
|
||||
throw new NotFoundException(MEDICINE_NOT_FOUND);
|
||||
}
|
||||
if (!medicine.active) {
|
||||
throw new BadRequestException('Das Mittel ist deaktiviert.');
|
||||
}
|
||||
return medicine;
|
||||
}
|
||||
|
||||
private async assertMedicineChangeable(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: LotRow,
|
||||
readMedicineId: string | null,
|
||||
dto: UpdateLotDto,
|
||||
): Promise<void> {
|
||||
if (readMedicineId !== before.medicineId) {
|
||||
throw new ConflictException(
|
||||
'Die Packung wurde gerade geändert. Bitte neu laden.',
|
||||
);
|
||||
}
|
||||
const target = await this.assertMedicineActive(tx, dto.medicineId);
|
||||
if (target.unit !== before.medicine.unit) {
|
||||
throw new BadRequestException(
|
||||
'Die Einheit des Mittels passt nicht zur Packung.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(await tx.veterinaryMedicineMovement.count({
|
||||
where: { lotId: before.id },
|
||||
})) > 0
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'Das Mittel ist festgelegt, sobald Buchungen existieren',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async detail(db: DbClient, id: string): Promise<LotDetailView> {
|
||||
const lot = await db.veterinaryMedicineLot.findUnique({
|
||||
where: { id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
});
|
||||
if (!lot) {
|
||||
throw new NotFoundException(LOT_NOT_FOUND);
|
||||
}
|
||||
const [totals, movements] = await Promise.all([
|
||||
lotTotals(db, [id]),
|
||||
db.veterinaryMedicineMovement.findMany({
|
||||
where: { lotId: id },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
quantity: true,
|
||||
treatmentId: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ...toLotView(lot, totals.get(id)), movements };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
import { UpdateLotDto } from './dto/update-lot.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateVeterinaryMedicineDto: {
|
||||
dto: UpdateVeterinaryMedicineDto,
|
||||
notNull: ['name', 'unit', 'prescriptionOnly'],
|
||||
clearable: [
|
||||
'activeIngredient',
|
||||
'authorisationNumber',
|
||||
'withdrawalPeriodDays',
|
||||
'dosePerColony',
|
||||
'doseNote',
|
||||
'applicationMethod',
|
||||
'minTemperatureC',
|
||||
'maxTemperatureC',
|
||||
'defaultSupplier',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
UpdateLotDto: {
|
||||
dto: UpdateLotDto,
|
||||
notNull: ['medicineId', 'purchasedOn', 'quantity'],
|
||||
clearable: [
|
||||
'batchNumber',
|
||||
'expiresOn',
|
||||
'supplier',
|
||||
'purchaseProof',
|
||||
'prescribingVet',
|
||||
'notes',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Veterinary medicine update DTOs', contracts);
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
|
||||
import { MedicineListQuery } from './dto/medicine-list-query.dto';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
@ApiTags('veterinary-medicines')
|
||||
@ApiBearerAuth()
|
||||
@Controller('veterinary-medicines')
|
||||
export class VeterinaryMedicinesController {
|
||||
constructor(private readonly medicines: VeterinaryMedicinesService) {}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get()
|
||||
findAll(@Query() query: MedicineListQuery) {
|
||||
return this.medicines.findAll(query.active);
|
||||
}
|
||||
|
||||
// Before ':id', so 'suggestions' is not read as an id.
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get('suggestions')
|
||||
suggestions() {
|
||||
return this.medicines.suggestions();
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.medicines.findOne(id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateVeterinaryMedicineDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.medicines.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateVeterinaryMedicineDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.medicines.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Post(':id/deactivate')
|
||||
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.medicines.setActive(id, false, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('veterinary-medicines:update')
|
||||
@Post(':id/reactivate')
|
||||
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||
return this.medicines.setActive(id, true, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { VeterinaryMedicineLotsController } from './veterinary-medicine-lots.controller';
|
||||
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
|
||||
import { VeterinaryMedicinesController } from './veterinary-medicines.controller';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
VeterinaryMedicinesController,
|
||||
VeterinaryMedicineLotsController,
|
||||
],
|
||||
providers: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
|
||||
exports: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
|
||||
})
|
||||
export class VeterinaryMedicinesModule {}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
|
||||
|
||||
jest.mock('../prisma/row-lock', () => ({
|
||||
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const medicine = {
|
||||
id: 'm1',
|
||||
name: 'Mittel A',
|
||||
activeIngredient: null,
|
||||
authorisationNumber: null,
|
||||
prescriptionOnly: false,
|
||||
withdrawalPeriodDays: null,
|
||||
unit: 'ML',
|
||||
dosePerColony: null,
|
||||
doseNote: null,
|
||||
applicationMethod: null,
|
||||
minTemperatureC: null,
|
||||
maxTemperatureC: null,
|
||||
defaultSupplier: null,
|
||||
notes: null,
|
||||
active: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
||||
describe('VeterinaryMedicinesService', () => {
|
||||
let service: VeterinaryMedicinesService;
|
||||
let tx: {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
veterinaryMedicineLot: { count: jest.Mock; findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
veterinaryMedicine: { findMany: jest.Mock };
|
||||
veterinaryMedicineLot: { findMany: jest.Mock };
|
||||
veterinaryMedicineMovement: { groupBy: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
tx = {
|
||||
veterinaryMedicine: {
|
||||
findUnique: jest.fn().mockResolvedValue(medicine),
|
||||
create: jest.fn().mockResolvedValue(medicine),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: { data: object }) =>
|
||||
Promise.resolve({ ...medicine, ...data }),
|
||||
),
|
||||
},
|
||||
veterinaryMedicineLot: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
prisma = {
|
||||
$transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
|
||||
$queryRaw: jest.fn(),
|
||||
veterinaryMedicine: { findMany: jest.fn() },
|
||||
veterinaryMedicineLot: { findMany: jest.fn() },
|
||||
veterinaryMedicineMovement: { groupBy: jest.fn() },
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
service = new VeterinaryMedicinesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects changing the unit once a lot exists with 409', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
|
||||
|
||||
await expect(
|
||||
service.update('m1', { unit: 'G' }, 'u1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the unit it already has even with lots', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
|
||||
|
||||
await service.update('m1', { unit: 'ML' }, 'u1');
|
||||
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows editing every other field of a medicine with lots', async () => {
|
||||
tx.veterinaryMedicineLot.count.mockResolvedValue(3);
|
||||
|
||||
const view = await service.update(
|
||||
'm1',
|
||||
{ name: 'Neu', withdrawalPeriodDays: 0, notes: null },
|
||||
'u1',
|
||||
);
|
||||
|
||||
expect(view.name).toBe('Neu');
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
|
||||
where: { id: 'm1' },
|
||||
data: { name: 'Neu', withdrawalPeriodDays: 0, notes: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'VeterinaryMedicine',
|
||||
action: 'UPDATE',
|
||||
beforeData: medicine,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the medicine before it reads the row to change', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx.veterinaryMedicine.findUnique.mockImplementation(() => {
|
||||
order.push('read');
|
||||
return Promise.resolve(medicine);
|
||||
});
|
||||
|
||||
await service.update('m1', { name: 'Neu' }, 'u1');
|
||||
|
||||
expect(order).toEqual(['lock', 'read']);
|
||||
});
|
||||
|
||||
it('setActive without a change returns the view and writes no audit row', async () => {
|
||||
const view = await service.setActive('m1', true, 'u1');
|
||||
|
||||
expect(view.active).toBe(true);
|
||||
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('setActive audits a real change', async () => {
|
||||
await service.setActive('m1', false, 'u1');
|
||||
|
||||
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
|
||||
where: { id: 'm1' },
|
||||
data: { active: false },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a minimum above the maximum temperature with 400', async () => {
|
||||
tx.veterinaryMedicine.findUnique.mockResolvedValue({
|
||||
...medicine,
|
||||
maxTemperatureC: 20,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('m1', { minTemperatureC: 25 }, 'u1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates a medicine with its audit entry in one transaction', async () => {
|
||||
const view = await service.create({ name: 'Mittel A', unit: 'ML' }, 'u1');
|
||||
|
||||
expect(view).toMatchObject({ id: 'm1', totalRemaining: 0, lotCount: 0 });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'CREATE', entityId: 'm1' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('suggestions skip names already in the catalogue regardless of case and give the latest values', async () => {
|
||||
prisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
name: 'Oxuvar',
|
||||
treatmentCount: 4,
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.suggestions();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'Oxuvar',
|
||||
treatmentCount: 4,
|
||||
latest: {
|
||||
activeIngredient: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 0,
|
||||
method: 'Träufeln',
|
||||
amount: '5 ml',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const query = (prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0];
|
||||
expect(query.sql).toContain('lower(btrim(');
|
||||
expect(query.sql).toContain('NOT EXISTS');
|
||||
expect(query.sql).toContain('LIMIT 50');
|
||||
});
|
||||
|
||||
it('lists the catalogue with the total rest and the next expiry without a query per medicine', async () => {
|
||||
prisma.veterinaryMedicine.findMany.mockResolvedValue([
|
||||
medicine,
|
||||
{ ...medicine, id: 'm2', name: 'Mittel B' },
|
||||
]);
|
||||
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'l1',
|
||||
medicineId: 'm1',
|
||||
quantity: 10,
|
||||
expiresOn: new Date('2027-05-01T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
medicineId: 'm1',
|
||||
quantity: 4,
|
||||
expiresOn: new Date('2026-12-01T00:00:00Z'),
|
||||
},
|
||||
{ id: 'l3', medicineId: 'm1', quantity: 2, expiresOn: null },
|
||||
]);
|
||||
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
|
||||
{ lotId: 'l1', _sum: { quantity: -10 }, _count: { _all: 2 } },
|
||||
]);
|
||||
|
||||
const list = await service.findAll();
|
||||
|
||||
expect(prisma.veterinaryMedicineMovement.groupBy).toHaveBeenCalledTimes(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
id: 'm1',
|
||||
lotCount: 3,
|
||||
// l1 is used up (0), l2 has 4, l3 has 2
|
||||
totalRemaining: 6,
|
||||
// l1 has nothing left, so the next expiry is l2's
|
||||
nextExpiry: '2026-12-01',
|
||||
});
|
||||
expect(list[1]).toMatchObject({
|
||||
id: 'm2',
|
||||
lotCount: 0,
|
||||
totalRemaining: 0,
|
||||
nextExpiry: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, VeterinaryMedicine } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
toLotView,
|
||||
lotTotals,
|
||||
toMedicineView,
|
||||
LOT_MEDICINE_INCLUDE,
|
||||
} from './medicine-views';
|
||||
import type {
|
||||
LotView,
|
||||
MedicineSuggestion,
|
||||
MedicineView,
|
||||
} from './medicine-ledger';
|
||||
import { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
|
||||
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
|
||||
|
||||
const AUDIT_ENTITY = 'VeterinaryMedicine';
|
||||
const SUGGESTION_LIMIT = 50;
|
||||
|
||||
interface SuggestionRow {
|
||||
name: string;
|
||||
treatmentCount: number;
|
||||
activeIngredient: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
method: string | null;
|
||||
amount: string | null;
|
||||
}
|
||||
|
||||
function assertTemperatureRange(
|
||||
min: number | null | undefined,
|
||||
max: number | null | undefined,
|
||||
): void {
|
||||
if (min != null && max != null && min > max) {
|
||||
throw new BadRequestException(
|
||||
'Die Mindesttemperatur darf nicht über der Höchsttemperatur liegen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VeterinaryMedicinesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/** The catalogue with total rest and next expiry - two grouped queries, no N+1. */
|
||||
async findAll(active?: boolean): Promise<MedicineView[]> {
|
||||
const medicines = await this.prisma.veterinaryMedicine.findMany({
|
||||
where: { active },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const lots = await this.prisma.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: { in: medicines.map((medicine) => medicine.id) } },
|
||||
select: { id: true, medicineId: true, quantity: true, expiresOn: true },
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
this.prisma,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return medicines.map((medicine) =>
|
||||
toMedicineView(
|
||||
medicine,
|
||||
lots.filter((lot) => lot.medicineId === medicine.id),
|
||||
totals,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<MedicineView & { lots: LotView[] }> {
|
||||
const medicine = await this.prisma.veterinaryMedicine.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!medicine) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
return this.viewWithLots(this.prisma, medicine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Own product names from treatments that are not in the catalogue yet
|
||||
* (case and surrounding blanks ignored), with the values of the latest
|
||||
* treatment. Nothing is created by looking at them.
|
||||
*/
|
||||
async suggestions(): Promise<MedicineSuggestion[]> {
|
||||
const rows = await this.prisma.$queryRaw<SuggestionRow[]>(Prisma.sql`
|
||||
SELECT s.name, s."treatmentCount", s."activeIngredient",
|
||||
s."withdrawalPeriodDays", s.method, s.amount
|
||||
FROM (
|
||||
SELECT DISTINCT ON (g.key) g.name, g."treatmentCount",
|
||||
g."activeIngredient", g."withdrawalPeriodDays", g.method, g.amount
|
||||
FROM (
|
||||
SELECT lower(btrim(t.product)) AS key, btrim(t.product) AS name,
|
||||
t.date, t."createdAt", t.id, t."activeIngredient",
|
||||
t."withdrawalPeriodDays", t.method, t.amount,
|
||||
count(*) OVER (PARTITION BY lower(btrim(t.product)))::int
|
||||
AS "treatmentCount"
|
||||
FROM "Treatment" t
|
||||
WHERE btrim(t.product) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "VeterinaryMedicine" m
|
||||
WHERE lower(btrim(m.name)) = lower(btrim(t.product))
|
||||
)
|
||||
) g
|
||||
ORDER BY g.key, g.date DESC, g."createdAt" DESC, g.id DESC
|
||||
) s
|
||||
ORDER BY s."treatmentCount" DESC, lower(s.name) ASC
|
||||
LIMIT ${Prisma.raw(String(SUGGESTION_LIMIT))}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
name: row.name,
|
||||
treatmentCount: Number(row.treatmentCount),
|
||||
latest: {
|
||||
activeIngredient: row.activeIngredient,
|
||||
withdrawalPeriodDays: row.withdrawalPeriodDays,
|
||||
method: row.method,
|
||||
amount: row.amount,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateVeterinaryMedicineDto,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
assertTemperatureRange(dto.minTemperatureC, dto.maxTemperatureC);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.veterinaryMedicine.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return toMedicineView(created, [], new Map());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit is fixed once a lot exists (409). The medicine row is locked
|
||||
* first: creating a lot takes the same lock, so the check cannot be raced.
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateVeterinaryMedicineDto,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockAndRead(tx, id);
|
||||
if (
|
||||
dto.unit !== undefined &&
|
||||
dto.unit !== before.unit &&
|
||||
(await tx.veterinaryMedicineLot.count({ where: { medicineId: id } })) >
|
||||
0
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'Einheit ist festgelegt, sobald Packungen erfasst sind',
|
||||
);
|
||||
}
|
||||
assertTemperatureRange(
|
||||
dto.minTemperatureC === undefined
|
||||
? before.minTemperatureC
|
||||
: dto.minTemperatureC,
|
||||
dto.maxTemperatureC === undefined
|
||||
? before.maxTemperatureC
|
||||
: dto.maxTemperatureC,
|
||||
);
|
||||
const updated = await tx.veterinaryMedicine.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
return this.recordUpdate(tx, before, updated, actingUserId);
|
||||
});
|
||||
}
|
||||
|
||||
/** History instead of deleting: an inactive medicine can no longer be treated with. */
|
||||
async setActive(
|
||||
id: string,
|
||||
active: boolean,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const before = await this.lockAndRead(tx, id);
|
||||
if (before.active === active) {
|
||||
// Nothing changes: no write and no audit row.
|
||||
return this.viewOf(tx, before);
|
||||
}
|
||||
const updated = await tx.veterinaryMedicine.update({
|
||||
where: { id },
|
||||
data: { active },
|
||||
});
|
||||
return this.recordUpdate(tx, before, updated, actingUserId);
|
||||
});
|
||||
}
|
||||
|
||||
private async lockAndRead(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<VeterinaryMedicine> {
|
||||
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', id))) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
const medicine = await tx.veterinaryMedicine.findUnique({ where: { id } });
|
||||
if (!medicine) {
|
||||
throw new NotFoundException('Tierarzneimittel nicht gefunden');
|
||||
}
|
||||
return medicine;
|
||||
}
|
||||
|
||||
private async recordUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
before: VeterinaryMedicine,
|
||||
updated: VeterinaryMedicine,
|
||||
actingUserId: string,
|
||||
): Promise<MedicineView> {
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: updated.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return this.viewOf(tx, updated);
|
||||
}
|
||||
|
||||
private async viewOf(
|
||||
tx: Prisma.TransactionClient,
|
||||
medicine: VeterinaryMedicine,
|
||||
): Promise<MedicineView> {
|
||||
const lots = await tx.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: medicine.id },
|
||||
select: { id: true, quantity: true, expiresOn: true },
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
tx,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return toMedicineView(medicine, lots, totals);
|
||||
}
|
||||
|
||||
private async viewWithLots(
|
||||
db: DbClient,
|
||||
medicine: VeterinaryMedicine,
|
||||
): Promise<MedicineView & { lots: LotView[] }> {
|
||||
const lots = await db.veterinaryMedicineLot.findMany({
|
||||
where: { medicineId: medicine.id },
|
||||
include: LOT_MEDICINE_INCLUDE,
|
||||
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const totals = await lotTotals(
|
||||
db,
|
||||
lots.map((lot) => lot.id),
|
||||
);
|
||||
return {
|
||||
...toMedicineView(medicine, lots, totals),
|
||||
lots: lots.map((lot) => toLotView(lot, totals.get(lot.id))),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,20 @@ describe('WebhookEventHandlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('none of the four new events is forwarded to webhooks', async () => {
|
||||
const herdEvents = [
|
||||
['ColonyCreated', { apiaryId: 'a1' }],
|
||||
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
|
||||
['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
|
||||
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
|
||||
] as const;
|
||||
for (const [type, payload] of herdEvents) {
|
||||
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
|
||||
await forward(envelope(type, 'x1', payload as never));
|
||||
}
|
||||
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('registers forward as "webhooks.forward" for every mapped type', () => {
|
||||
// The decorator stores its metadata on the method itself.
|
||||
const method = Reflect.get(WebhookEventHandlers.prototype, 'forward');
|
||||
|
||||
Reference in new issue
Block a user