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

@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
Matches,
Max,
Min,
} from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
export const MIN_NOTICE_YEAR = 2000;
export const MAX_NOTICE_YEAR = 2100;
export class CreateAuthorityNoticeDto {
@ApiProperty({ enum: AuthorityNoticeKind })
@IsEnum(AuthorityNoticeKind)
kind!: AuthorityNoticeKind;
@ApiPropertyOptional({
description: 'Nur für die TSK-Zählung (dort Pflicht)',
})
@IsOptional()
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
@ApiPropertyOptional({
example: '2027-01-01',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({
example: '2026-05-01',
description:
'Gesetzt: die Anzeige entsteht gleich als gesendet (Altbestand)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
}
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
import { MAX_NOTICE_TEXT_LENGTH } from './update-authority-notice.dto';
export class MarkSentDto {
@ApiPropertyOptional({
example: '2027-01-15',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
@ApiPropertyOptional({ description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string;
@ApiPropertyOptional({ description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string;
}
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
import {
MAX_NOTICE_YEAR,
MIN_NOTICE_YEAR,
} from './create-authority-notice.dto';
export class NoticeListQuery {
@ApiPropertyOptional({ enum: AuthorityNoticeKind })
@IsOptional()
@IsEnum(AuthorityNoticeKind)
kind?: AuthorityNoticeKind;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
}
@@ -0,0 +1,55 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
export const MAX_NOTICE_TEXT_LENGTH = 300;
export const MAX_NOTICE_NOTES_LENGTH = 2000;
export class UpdateAuthorityNoticeDto {
@ApiPropertyOptional({
description: 'Die angezeigte Zahl; bleibt korrigierbar',
})
@IsOptionalNotNull()
@IsInt()
@Min(0)
totalColonies?: number;
@ApiPropertyOptional({ example: '2027-01-01' })
@IsOptionalNotNull()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({ nullable: true, example: '2027-01-15' })
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,104 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const MONTH_DAY_PATTERN = /^\d{2}-\d{2}$/;
const MONTH_DAY_MESSAGE = 'muss ein Tag im Format MM-TT sein';
const MAX_NAME_LENGTH = 200;
const MAX_ADDRESS_LENGTH = 300;
/** One row of the label digit height table. Annahme - vom Inhaber zu prüfen. */
export class NetQuantityDigitRuleDto {
@ApiProperty({
nullable: true,
description: 'Obergrenze in g, null = alles darüber',
})
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
maxGrams!: number | null;
@ApiProperty({ description: 'Mindesthöhe der Ziffern in mm' })
@IsNumber({ allowNaN: false, allowInfinity: false })
minMm!: number;
}
/**
* Legal values (deadlines, digit heights) are settings, not code. Every value
* here is an assumption the owner has to check.
*/
export class UpdateComplianceSettingsDto {
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
veterinaryOfficeName?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
veterinaryOfficeAddress?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
registrationNumber?: string | null;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnColonyCountChange?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnApiaryChange?: boolean;
@ApiPropertyOptional({ example: '01-01', description: 'MM-TT, ohne 02-29' })
@IsOptionalNotNull()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReferenceDay ${MONTH_DAY_MESSAGE}`,
})
tskReferenceDay?: string;
@ApiPropertyOptional({ nullable: true, example: '03-31' })
@IsOptional()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReportDueDay ${MONTH_DAY_MESSAGE}`,
})
tskReportDueDay?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
tskMemberNumber?: string | null;
@ApiPropertyOptional({ enum: ColonyType, isArray: true })
@IsOptionalNotNull()
@IsArray()
@IsEnum(ColonyType, { each: true })
tskExcludedColonyTypes?: ColonyType[];
@ApiPropertyOptional({ type: [NetQuantityDigitRuleDto] })
@IsOptionalNotNull()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NetQuantityDigitRuleDto)
netQuantityDigitRules?: NetQuantityDigitRuleDto[];
}