feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s

- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:18:41 +02:00
1 parent c18860c75c
commit edc193f128
73 files changed
+9470 -171

No files matched your search

+213 -34
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,9 +22,21 @@ import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
assertNoActiveRestriction,
RestrictionCheck,
RestrictionOverride,
} from '../compliance/restricted-zone';
import {
apiaryChangedMeanwhile,
NOT_OVERRIDDEN,
overrideNote,
overrideTrace,
} from './restriction-override';
function colonyMovedEvent(
colonyId: string,
@@ -40,6 +53,21 @@ function colonyMovedEvent(
};
}
/** The fields update() writes: the override reason is read, never stored. */
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
/**
* What update() hands to updateIn when it moves the colony to another apiary:
* the apiary the unlocked read saw (the apiary rows were locked for it) and
* the override the user may use.
*/
export interface ApiaryMoveGuard {
seenApiaryId: string;
override: RestrictionOverride;
}
const OVERRIDE_PERMISSION = 'compliance:manage';
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
@@ -52,6 +80,7 @@ export class ColoniesService {
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
private readonly permissions: PermissionResolverService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -84,11 +113,14 @@ export class ColoniesService {
// this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would).
private async assertApiaryActive(apiaryId: string): Promise<void> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
});
// apiary deactivated out from under an existing colony would). Read inside
// the transaction, after the apiary rows are locked (FOR SHARE): the
// apiary cannot be deactivated before this colony path commits.
private async assertApiaryActiveIn(
tx: Prisma.TransactionClient,
apiaryId: string,
): Promise<void> {
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
@@ -99,17 +131,49 @@ export class ColoniesService {
}
}
// Asked once per request, and only when a reason was sent (null counts as
// omitted): the right to override is compliance:manage.
private async overrideOf(
reason: string | null | undefined,
actingUserId: string,
): Promise<RestrictionOverride> {
if (!reason) {
return { reason: null, canOverride: false };
}
const held = await this.permissions.getPermissions(actingUserId);
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
// when no restriction is active. Checked before anything is read or locked.
if (!held.has(OVERRIDE_PERMISSION)) {
throw new ForbiddenException(
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
);
}
return { reason, canOverride: true };
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
await this.assertApiaryActiveIn(tx, data.apiaryId);
const check = await assertNoActiveRestriction(
tx,
[data.apiaryId],
berlinToday(),
override,
);
const created = await tx.colony.create({ data });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
afterData: { ...created, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -126,9 +190,25 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const { after } = await this.prisma.$transaction((tx) =>
this.updateIn(tx, id, dto, actingUserId),
const { restrictionOverrideReason, ...patch } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
// Only a change of the apiary involves apiaries: the old and the new one
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
const movesApiary =
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
const { after } = await this.prisma.$transaction(async (tx) => {
if (movesApiary) {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
}
return this.updateIn(tx, id, patch, actingUserId, {
seenApiaryId: seen.apiaryId,
override,
});
});
return after;
}
@@ -137,22 +217,44 @@ export class ColoniesService {
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*
* It locks no apiary itself. A patch that moves the colony to another
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
* before the colony, so the restriction check and the write cannot be
* overtaken by a restriction or a deactivation. The inspection takeover
* passes no apiary and no guard.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
dto: ColonyPatch,
actingUserId: string,
guard?: ApiaryMoveGuard,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id);
const today = berlinToday();
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
let check: RestrictionCheck = NOT_OVERRIDDEN;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
if (!guard) {
throw new Error(
'ColoniesService.updateIn: moving the apiary needs update()',
);
}
if (before.apiaryId !== guard.seenApiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
check = await assertNoActiveRestriction(
tx,
[before.apiaryId, dto.apiaryId!],
today,
guard.override,
);
}
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
@@ -173,7 +275,11 @@ export class ColoniesService {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
notes: overrideNote(
check,
guard?.override.reason,
'Korrektur über Volk bearbeiten',
),
},
});
await this.events.record(
@@ -196,7 +302,10 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: {
...updated,
...overrideTrace(check, guard?.override.reason),
},
},
tx,
);
@@ -208,21 +317,41 @@ export class ColoniesService {
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
this.assertActive(await this.findById(id));
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
this.assertActive(seen);
const colony = await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
const before = await lockColony(tx, id);
this.assertActive(before);
if (before.apiaryId !== seen.apiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, data.apiaryId);
// A "move" within one apiary leaves the herd where it is.
const check =
before.apiaryId === data.apiaryId
? NOT_OVERRIDDEN
: await assertNoActiveRestriction(
tx,
[before.apiaryId, data.apiaryId],
berlinToday(),
override,
);
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
data: { apiaryId: data.apiaryId },
});
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: dto.apiaryId,
apiaryId: data.apiaryId,
movedByUserId: actingUserId,
notes: dto.notes,
notes: overrideNote(check, override.reason, data.notes),
},
});
if (before.apiaryId !== updated.apiaryId) {
@@ -238,7 +367,7 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: { ...updated, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -313,24 +442,54 @@ export class ColoniesService {
dto: MergeColonyDto,
actingUserId: string,
): Promise<Colony> {
const { restrictionOverrideReason, ...data } = dto;
const survivor = await this.findById(id);
this.assertActive(survivor);
if (dto.mergedColonyIds.includes(survivor.id)) {
if (data.mergedColonyIds.includes(survivor.id)) {
throw new BadRequestException(
'Ein Volk kann nicht mit sich selbst vereinigt werden',
);
}
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
throw new BadRequestException(
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const today = berlinToday();
// The apiaries of the colonies as the unlocked read saw them: they are
// locked first, and compared with the locked rows afterwards.
const seenApiaries = new Map(
(
await this.prisma.colony.findMany({
where: { id: { in: data.mergedColonyIds } },
select: { id: true, apiaryId: true },
})
).map((colony) => [colony.id, colony.apiaryId]),
);
await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
]);
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
for (const mergedColonyId of dto.mergedColonyIds) {
await lockRowsForUpdate(tx, 'Colony', [
survivor.id,
...data.mergedColonyIds,
]);
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
if (!lockedSurvivor) {
throw new NotFoundException('Colony not found');
}
this.assertActive(lockedSurvivor);
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
throw apiaryChangedMeanwhile();
}
const mergedRows: Colony[] = [];
for (const mergedColonyId of data.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
});
@@ -340,6 +499,22 @@ export class ColoniesService {
);
}
this.assertActive(merged);
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
throw apiaryChangedMeanwhile();
}
mergedRows.push(merged);
}
// Uniting colonies of different apiaries brings bees from one stand to
// the other; within one apiary nothing leaves it.
const apiaryIds = new Set([
lockedSurvivor.apiaryId,
...mergedRows.map((merged) => merged.apiaryId),
]);
const check =
apiaryIds.size > 1
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
: NOT_OVERRIDDEN;
for (const merged of mergedRows) {
// currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict.
@@ -348,11 +523,11 @@ export class ColoniesService {
// merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
merged.id,
dayToDate(today),
);
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
where: { id: merged.id },
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await this.events.recordAll(
@@ -366,21 +541,25 @@ export class ColoniesService {
await tx.colonyLineage.create({
data: {
type: 'MERGE',
fromColonyId: mergedColonyId,
toColonyId: survivor.id,
fromColonyId: merged.id,
toColonyId: lockedSurvivor.id,
date: new Date(),
notes: dto.notes,
notes: data.notes,
},
});
}
await this.auditLog.record(
{
entityType: 'Colony',
entityId: survivor.id,
entityId: lockedSurvivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
beforeData: lockedSurvivor,
afterData: {
...lockedSurvivor,
mergedColonyIds: data.mergedColonyIds,
...overrideTrace(check, override.reason),
},
},
tx,
);