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
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AuthorityNotice,
|
||||
AuthorityNoticeKind,
|
||||
Prisma,
|
||||
} from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
|
||||
import { MarkSentDto } from './dto/mark-sent.dto';
|
||||
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
|
||||
import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot';
|
||||
import { COMPLIANCE_SETTINGS_ID, findBaselineIn } from './herd-status';
|
||||
|
||||
const AUDIT_ENTITY = 'AuthorityNotice';
|
||||
const LIST_LIMIT = 200;
|
||||
const UNIQUE_VIOLATION = 'P2002';
|
||||
|
||||
type NoticeUpdate = Prisma.AuthorityNoticeUncheckedUpdateInput;
|
||||
|
||||
interface DraftInput {
|
||||
kind: AuthorityNoticeKind;
|
||||
year?: number;
|
||||
referenceDate: Date;
|
||||
}
|
||||
|
||||
interface TransitionOptions {
|
||||
/** Takes the ComplianceSettings mutex first (the open key is involved). */
|
||||
mutex?: boolean;
|
||||
}
|
||||
|
||||
/** "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT:<year>": at most one draft each. */
|
||||
function openKeyOf(kind: AuthorityNoticeKind, year: number | null): string {
|
||||
return kind === 'TSK_REPORT' ? `TSK_REPORT:${year}` : kind;
|
||||
}
|
||||
|
||||
function trimmedOrNull(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function notAllowed(message: string): ConflictException {
|
||||
return new ConflictException({ statusCode: 409, message });
|
||||
}
|
||||
|
||||
function draftExists(existingId: string): ConflictException {
|
||||
return new ConflictException({
|
||||
statusCode: 409,
|
||||
code: 'DRAFT_EXISTS',
|
||||
message: 'Es gibt schon einen offenen Entwurf dieser Anzeige.',
|
||||
existingId,
|
||||
});
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
|
||||
function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
|
||||
if (kind === 'TSK_REPORT' && year === undefined) {
|
||||
throw new BadRequestException('Die TSK-Zählung braucht ein Jahr');
|
||||
}
|
||||
if (kind !== 'TSK_REPORT' && year !== undefined) {
|
||||
throw new BadRequestException('Ein Jahr gibt es nur bei der TSK-Zählung');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthorityNoticesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
findAll(q: {
|
||||
kind?: AuthorityNoticeKind;
|
||||
year?: number;
|
||||
}): Promise<AuthorityNotice[]> {
|
||||
return this.prisma.authorityNotice.findMany({
|
||||
where: { kind: q.kind, year: q.year },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: LIST_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<AuthorityNotice> {
|
||||
const notice = await this.prisma.authorityNotice.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!notice) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a draft, or a SENT notice at once when `sentOn` is given (the
|
||||
* way to record what was already reported). A second open draft of the
|
||||
* same kind and year answers 409 DRAFT_EXISTS with the id of the first.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateAuthorityNoticeDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
assertYearFits(dto.kind, dto.year);
|
||||
const input = {
|
||||
kind: dto.kind,
|
||||
year: dto.year,
|
||||
referenceDate: dayToDate(dto.referenceDate ?? berlinToday()),
|
||||
sentOn: dto.sentOn ? dayToDate(dto.sentOn) : undefined,
|
||||
};
|
||||
try {
|
||||
return await this.prisma.$transaction((tx) =>
|
||||
this.insertIn(tx, input, actingUserId),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) {
|
||||
throw error;
|
||||
}
|
||||
const winner = await this.prisma.authorityNotice.findFirst({
|
||||
where: { openKey: openKeyOf(dto.kind, dto.year ?? null) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (winner) {
|
||||
throw draftExists(winner.id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared core of create: the daily TSK job calls it in its own transaction. */
|
||||
createDraftIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: DraftInput,
|
||||
userId: string | null,
|
||||
): Promise<AuthorityNotice> {
|
||||
assertYearFits(input.kind, input.year);
|
||||
return this.insertIn(tx, input, userId);
|
||||
}
|
||||
|
||||
/** Replaces the herd picture and the total; only a draft can be recounted. */
|
||||
recount(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, {}, async (tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
throw notAllowed('Nur ein Entwurf kann neu gezählt werden.');
|
||||
}
|
||||
const countedAt = new Date();
|
||||
const snapshot = await this.countIn(tx, before.kind, countedAt);
|
||||
return {
|
||||
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||
totalColonies: snapshot.totalLiving,
|
||||
countedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
markSent(
|
||||
id: string,
|
||||
dto: MarkSentDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
return Promise.reject(
|
||||
notAllowed('Nur ein Entwurf kann als gesendet markiert werden.'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 'SENT',
|
||||
sentOn: dayToDate(dto.sentOn ?? berlinToday()),
|
||||
sentVia: trimmedOrNull(dto.sentVia),
|
||||
reference: trimmedOrNull(dto.reference),
|
||||
openKey: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
reopen(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(
|
||||
id,
|
||||
actingUserId,
|
||||
{ mutex: true },
|
||||
async (tx, before) => {
|
||||
if (before.status !== 'SENT') {
|
||||
throw notAllowed('Nur eine gesendete Anzeige kann geöffnet werden.');
|
||||
}
|
||||
const openKey = openKeyOf(before.kind, before.year);
|
||||
const other = await tx.authorityNotice.findFirst({
|
||||
where: { openKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (other) {
|
||||
throw draftExists(other.id);
|
||||
}
|
||||
return { status: 'DRAFT', openKey, sentOn: null };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cancel(id: string, actingUserId: string): Promise<AuthorityNotice> {
|
||||
return this.transition(id, actingUserId, {}, (_tx, before) => {
|
||||
if (before.status !== 'DRAFT') {
|
||||
return Promise.reject(
|
||||
notAllowed('Nur ein Entwurf kann verworfen werden.'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({ status: 'CANCELLED', openKey: null });
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
dto: UpdateAuthorityNoticeDto,
|
||||
actingUserId: string,
|
||||
): Promise<AuthorityNotice> {
|
||||
// A new sent day can change which notice is the baseline: the mutex
|
||||
// comes first, in lock order, whenever the sent day is touched.
|
||||
const mutex = dto.sentOn !== undefined;
|
||||
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
|
||||
if (before.status === 'CANCELLED') {
|
||||
return Promise.reject(
|
||||
notAllowed('Eine verworfene Anzeige kann nicht geändert werden.'),
|
||||
);
|
||||
}
|
||||
if (before.status === 'DRAFT' && dto.sentOn != null) {
|
||||
return Promise.reject(
|
||||
new BadRequestException('Ein Entwurf hat kein Sendedatum.'),
|
||||
);
|
||||
}
|
||||
if (before.status === 'SENT' && dto.sentOn === null) {
|
||||
return Promise.reject(
|
||||
new BadRequestException(
|
||||
'Eine gesendete Anzeige braucht ihr Sendedatum',
|
||||
),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
totalColonies: dto.totalColonies,
|
||||
referenceDate: dto.referenceDate
|
||||
? dayToDate(dto.referenceDate)
|
||||
: undefined,
|
||||
sentOn: optionalDate(dto.sentOn),
|
||||
sentVia: trimmedOrNull(dto.sentVia),
|
||||
reference: trimmedOrNull(dto.reference),
|
||||
notes: trimmedOrNull(dto.notes),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async countIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
kind: AuthorityNoticeKind,
|
||||
countedAt: Date,
|
||||
) {
|
||||
const settings = await tx.complianceSettings.findUniqueOrThrow({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
return buildHerdSnapshot(
|
||||
await loadHerdApiaryRows(tx),
|
||||
countedAt,
|
||||
kind === 'TSK_REPORT' ? settings.tskExcludedColonyTypes : [],
|
||||
);
|
||||
}
|
||||
|
||||
private async insertIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: DraftInput & { sentOn?: Date },
|
||||
userId: string | null,
|
||||
): Promise<AuthorityNotice> {
|
||||
await this.lockSettingsIn(tx);
|
||||
const baseline = await findBaselineIn(tx);
|
||||
if (input.kind === 'REGISTRATION' && baseline) {
|
||||
throw notAllowed('Die Erstanzeige ist schon erfasst.');
|
||||
}
|
||||
if (input.kind === 'HERD_CHANGE' && !baseline) {
|
||||
throw notAllowed('Erst die Erstanzeige erfassen.');
|
||||
}
|
||||
const openKey = openKeyOf(input.kind, input.year ?? null);
|
||||
// A notice recorded as already sent has no open key and cannot collide.
|
||||
const draft = input.sentOn
|
||||
? null
|
||||
: await tx.authorityNotice.findFirst({
|
||||
where: { openKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (draft) {
|
||||
throw draftExists(draft.id);
|
||||
}
|
||||
const countedAt = new Date();
|
||||
const snapshot = await this.countIn(tx, input.kind, countedAt);
|
||||
const created = await tx.authorityNotice.create({
|
||||
data: {
|
||||
kind: input.kind,
|
||||
status: input.sentOn ? 'SENT' : 'DRAFT',
|
||||
year: input.year ?? null,
|
||||
referenceDate: input.referenceDate,
|
||||
countedAt,
|
||||
snapshot: snapshot as unknown as Prisma.InputJsonValue,
|
||||
totalColonies: snapshot.totalLiving,
|
||||
sentOn: input.sentOn ?? null,
|
||||
openKey: input.sentOn ? null : openKey,
|
||||
createdByUserId: userId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: userId ?? undefined,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** The ComplianceSettings row is the mutex of everything around the open key. */
|
||||
private async lockSettingsIn(tx: Prisma.TransactionClient): Promise<void> {
|
||||
await tx.complianceSettings.upsert({
|
||||
where: { id: COMPLIANCE_SETTINGS_ID },
|
||||
update: {},
|
||||
create: { id: COMPLIANCE_SETTINGS_ID },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* One state change: lock (settings mutex first when asked, then the notice
|
||||
* row), read "before" under the lock, check the transition, write, audit.
|
||||
*/
|
||||
private async transition(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
options: TransitionOptions,
|
||||
change: (
|
||||
tx: Prisma.TransactionClient,
|
||||
before: AuthorityNotice,
|
||||
) => Promise<NoticeUpdate>,
|
||||
): Promise<AuthorityNotice> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (options.mutex) {
|
||||
await this.lockSettingsIn(tx);
|
||||
}
|
||||
if (!(await lockRowForUpdate(tx, 'AuthorityNotice', id))) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
const before = await tx.authorityNotice.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Anzeige nicht gefunden');
|
||||
}
|
||||
const after = await tx.authorityNotice.update({
|
||||
where: { id },
|
||||
data: await change(tx, before),
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: after,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user