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
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user