feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit, bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot, FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export needs veterinary-medicines:read. - Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with watermark, PRINT audit before the PDF, check endpoints; digit rules only from the seeded ComplianceSettings row (fail closed). - Restricted zone (Berlin day): create, update, move, merge and CSV import blocked inside an active restriction; override with reason and compliance:manage, audited, never stored on the colony; split, harvest and dissolve stay free; restriction details only with compliance:read. - Authority notices: settings, herd snapshot, registration/herd change/TSK notices with one open draft per kind under the settings lock. - Label check refinements (geometric reading order, number|unit pairs). - ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user