- 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]>
225 lines
7.0 KiB
TypeScript
225 lines
7.0 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ApiaryRestriction } from '../../generated/prisma';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { berlinToday, dayToDate } from '../common/dates';
|
|
import { optionalDate } from '../common/patch-values';
|
|
import {
|
|
ActiveRestrictionInfo,
|
|
isRestrictionActive,
|
|
startedRestrictions,
|
|
toActiveRestrictionInfo,
|
|
} from '../compliance/restricted-zone';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
|
import {
|
|
CreateApiaryRestrictionDto,
|
|
MAX_RESTRICTION_APIARIES,
|
|
} from './dto/create-apiary-restriction.dto';
|
|
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
|
|
|
const AUDIT_ENTITY = 'ApiaryRestriction';
|
|
|
|
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
|
|
export interface RestrictionSummary {
|
|
id: string;
|
|
apiaryId: string;
|
|
disease: string;
|
|
startsOn: string;
|
|
endsOn: string | null;
|
|
}
|
|
|
|
function toSummary(row: ApiaryRestriction): RestrictionSummary {
|
|
return {
|
|
id: row.id,
|
|
apiaryId: row.apiaryId,
|
|
disease: row.disease,
|
|
startsOn: row.startsOn.toISOString().slice(0, 10),
|
|
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
|
|
};
|
|
}
|
|
|
|
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
|
|
if (endsOn !== null && endsOn < startsOn) {
|
|
throw new BadRequestException(
|
|
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
|
|
* it ends by an endsOn date, so the colony history stays explainable.
|
|
*
|
|
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
|
|
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
|
|
* restriction and a move into that apiary queue up behind each other.
|
|
*/
|
|
@Injectable()
|
|
export class ApiaryRestrictionsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
) {}
|
|
|
|
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
|
|
async create(
|
|
dto: CreateApiaryRestrictionDto,
|
|
actingUserId: string,
|
|
): Promise<ApiaryRestriction[]> {
|
|
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
|
|
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
|
|
throw new BadRequestException(
|
|
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
|
|
);
|
|
}
|
|
const startsOn = dayToDate(dto.startsOn);
|
|
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
|
|
assertEndNotBeforeStart(startsOn, endsOn);
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
|
|
const known = await tx.apiary.findMany({
|
|
where: { id: { in: apiaryIds } },
|
|
select: { id: true },
|
|
});
|
|
if (known.length !== apiaryIds.length) {
|
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
|
}
|
|
const created: ApiaryRestriction[] = [];
|
|
for (const apiaryId of apiaryIds) {
|
|
const restriction = await tx.apiaryRestriction.create({
|
|
data: {
|
|
apiaryId,
|
|
disease: dto.disease,
|
|
authority: dto.authority,
|
|
reference: dto.reference,
|
|
startsOn,
|
|
endsOn,
|
|
notes: dto.notes,
|
|
createdByUserId: actingUserId,
|
|
},
|
|
});
|
|
await this.auditLog.record(
|
|
{
|
|
entityType: AUDIT_ENTITY,
|
|
entityId: restriction.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: restriction,
|
|
},
|
|
tx,
|
|
);
|
|
created.push(restriction);
|
|
}
|
|
return created;
|
|
});
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateApiaryRestrictionDto,
|
|
actingUserId: string,
|
|
): Promise<ApiaryRestriction> {
|
|
const seen = await this.prisma.apiaryRestriction.findUnique({
|
|
where: { id },
|
|
select: { apiaryId: true },
|
|
});
|
|
if (!seen) {
|
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
|
}
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
|
|
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
|
|
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
|
|
if (!before) {
|
|
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
|
}
|
|
const startsOnPatch =
|
|
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
|
|
const startsOn = startsOnPatch ?? before.startsOn;
|
|
const endsOn = optionalDate(dto.endsOn);
|
|
assertEndNotBeforeStart(
|
|
startsOn,
|
|
endsOn === undefined ? before.endsOn : endsOn,
|
|
);
|
|
const updated = await tx.apiaryRestriction.update({
|
|
where: { id },
|
|
data: {
|
|
disease: dto.disease,
|
|
authority: dto.authority,
|
|
reference: dto.reference,
|
|
startsOn: startsOnPatch,
|
|
endsOn,
|
|
notes: dto.notes,
|
|
},
|
|
});
|
|
await this.auditLog.record(
|
|
{
|
|
entityType: AUDIT_ENTITY,
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: updated,
|
|
},
|
|
tx,
|
|
);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
/** The restrictions in force today (Berlin day), with the apiary name. */
|
|
async findActive(): Promise<ActiveRestrictionInfo[]> {
|
|
const today = berlinToday();
|
|
const started = await startedRestrictions(this.prisma, undefined, today);
|
|
return started
|
|
.filter((row) => isRestrictionActive(row, today))
|
|
.map(toActiveRestrictionInfo);
|
|
}
|
|
|
|
/**
|
|
* Every restriction (newest first); `active` narrows to in force / ended.
|
|
* Authority, file reference, notes and the creator are for callers with
|
|
* compliance:read (`detailed`); everybody else gets the summary.
|
|
*/
|
|
async list(
|
|
active?: boolean,
|
|
detailed = true,
|
|
): Promise<
|
|
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
|
|
> {
|
|
if (active === true) {
|
|
return this.findActive();
|
|
}
|
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
|
});
|
|
const wanted =
|
|
active === false
|
|
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
|
|
: rows;
|
|
return detailed ? wanted : wanted.map(toSummary);
|
|
}
|
|
|
|
async findForApiary(
|
|
apiaryId: string,
|
|
detailed = true,
|
|
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
|
|
const apiary = await this.prisma.apiary.findUnique({
|
|
where: { id: apiaryId },
|
|
select: { id: true },
|
|
});
|
|
if (!apiary) {
|
|
throw new NotFoundException('Bienenstand nicht gefunden');
|
|
}
|
|
const rows = await this.prisma.apiaryRestriction.findMany({
|
|
where: { apiaryId },
|
|
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
|
});
|
|
return detailed ? rows : rows.map(toSummary);
|
|
}
|
|
}
|