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