diff --git a/ApiCore-Backend/prisma/migrations/20261007100000_raw_material_production_automation/migration.sql b/ApiCore-Backend/prisma/migrations/20261007100000_raw_material_production_automation/migration.sql new file mode 100644 index 0000000..05db768 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261007100000_raw_material_production_automation/migration.sql @@ -0,0 +1,4 @@ +-- AlterEnum +ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT'; +ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT'; +ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW'; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 3246aaa..3aee330 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -1346,6 +1346,9 @@ enum AutomationTriggerType { BOTTLING_LOT_NUMBER_FORMAT TREATMENT_FOLLOW_UP FEEDING_FOLLOW_UP + RAW_MATERIAL_LOT_NUMBER_FORMAT + PRODUCTION_BATCH_NUMBER_FORMAT + RAW_MATERIAL_LOW } model AutomationRule { @@ -1354,8 +1357,8 @@ model AutomationRule { active Boolean @default(true) config Json? actionTemplate String? - // Running counter for the number-format rules (HONEY_BATCH_NUMBER_FORMAT / - // BOTTLING_LOT_NUMBER_FORMAT) - unused by the threshold-style rules. Kept as + // Running counter for the number-format rules (*_NUMBER_FORMAT) - unused + // by the threshold-style rules. Kept as // a dedicated column (not inside `config`) so it can be incremented with a // single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`, // race-safe under concurrent batch/bottling creation. diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 9534c98..912e799 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -338,6 +338,60 @@ describe('AutomationService', () => { }); }); + describe('evaluateRawMaterialLow', () => { + const rawRule = { + id: 'rule-7', + triggerType: 'RAW_MATERIAL_LOW', + active: true, + config: { minGrams: { BEESWAX: 1000 } }, + actionTemplate: 'Rohstoff unter Mindestmenge', + }; + + it('creates one task per kind when the kind drops below its minimum', async () => { + prisma.automationRule.findUnique.mockResolvedValue(rawRule); + + await service.evaluateRawMaterialLow('BEESWAX', 400); + + const [createArgs] = prisma.task.create.mock.calls[0] as [unknown]; + expect(createArgs).toMatchObject({ + data: { + relatedType: 'GENERAL', + relatedId: 'BEESWAX', + sourceAutomationTrigger: 'RAW_MATERIAL_LOW', + description: + 'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.', + }, + }); + }); + + it('does not trigger at or above the minimum', async () => { + prisma.automationRule.findUnique.mockResolvedValue(rawRule); + + await service.evaluateRawMaterialLow('BEESWAX', 1000); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('does not trigger for a kind without a minimum', async () => { + prisma.automationRule.findUnique.mockResolvedValue(rawRule); + + await service.evaluateRawMaterialLow('PROPOLIS', 0); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + + it('does not trigger when the rule is inactive', async () => { + prisma.automationRule.findUnique.mockResolvedValue({ + ...rawRule, + active: false, + }); + + await service.evaluateRawMaterialLow('BEESWAX', 0); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + describe('update', () => { it('records an audit log entry with before/after data', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 9a2fb24..c0a7682 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -10,6 +10,7 @@ import { Inspection, Feeding, Prisma, + RawMaterialKind, StockItem, SwarmControlCheck, TaskRelatedType, @@ -19,6 +20,18 @@ import { import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { formatGeneratedNumber } from './number-format'; +/** The rules that fill in a batch/lot number instead of creating tasks. */ +export type NumberFormatTrigger = + | 'HONEY_BATCH_NUMBER_FORMAT' + | 'BOTTLING_LOT_NUMBER_FORMAT' + | 'RAW_MATERIAL_LOT_NUMBER_FORMAT' + | 'PRODUCTION_BATCH_NUMBER_FORMAT'; + +const RAW_MATERIAL_KIND_LABELS: Record = { + BEESWAX: 'Bienenwachs', + PROPOLIS: 'Propolis', +}; + interface RuleDefaults { config: Record; actionTemplate: string; @@ -64,6 +77,22 @@ const DEFAULT_RULES: Record = { config: { format: 'WH-{YYYY}-{SEQ:4}' }, actionTemplate: '', }, + RAW_MATERIAL_LOT_NUMBER_FORMAT: { + config: { format: 'RS-{YYYY}-{SEQ:4}' }, + actionTemplate: '', + }, + PRODUCTION_BATCH_NUMBER_FORMAT: { + config: { format: 'HS-{YYYY}-{SEQ:4}' }, + actionTemplate: '', + }, + // The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry + // no minimum of their own, so the minimum per kind (config.minGrams) lives + // here - and without one there is nothing to compare, hence off by default. + RAW_MATERIAL_LOW: { + config: {}, + actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.', + active: false, + }, // Follow-up checks: how many days after a treatment or feeding a check // makes sense depends on the product and the season, so there is no // built-in interval - the beekeeper switches the rule on and sets @@ -286,6 +315,36 @@ export class AutomationService { await this.triggerAction(rule, 'STORAGE', item.id); } + /** + * After raw material left its lots (consumption, withdrawal, inventory): + * compares what is left of the kind across all lots with config.minGrams. + * The task is about the kind, not one lot - a new lot of the same kind is + * the remedy - so it hangs on GENERAL with the kind as relatedId, which + * keeps one open task per kind. + */ + async evaluateRawMaterialLow( + kind: RawMaterialKind, + remainingGrams: number, + ): Promise { + await this.ensureDefaults(); + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'RAW_MATERIAL_LOW' }, + }); + if (!rule || !rule.active) { + return; + } + const config = rule.config as { + minGrams?: Partial>; + } | null; + const minGrams = config?.minGrams?.[kind]; + if (typeof minGrams !== 'number' || remainingGrams >= minGrams) { + return; + } + await this.triggerAction(rule, 'GENERAL', kind, undefined, { + description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`, + }); + } + async evaluateTreatment(treatment: Treatment): Promise { await this.evaluateFollowUp( 'TREATMENT_FOLLOW_UP', @@ -327,7 +386,7 @@ export class AutomationService { // Returns null (never throws) when the rule is inactive or has no format // configured, so the caller can fall back to requiring manual entry. async generateNumber( - triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT', + triggerType: NumberFormatTrigger, ): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ diff --git a/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts b/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts index 25155a3..67634c7 100644 --- a/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts +++ b/ApiCore-Backend/src/production-batches/dto/create-production-batch.dto.ts @@ -30,11 +30,15 @@ export class CreateProductionBatchDto { @IsString() productId!: string; - @ApiProperty() + @ApiPropertyOptional({ + description: + 'Leer lassen, wenn die Automatisierung "Chargennummer-Format (Herstellung)" aktiv ist', + }) + @IsOptional() @IsString() @IsNotEmpty() @MaxLength(100) - lotNumber!: string; + lotNumber?: string; @ApiProperty({ description: 'Herstelldatum' }) @IsDateString() diff --git a/ApiCore-Backend/src/production-batches/production-batches.module.ts b/ApiCore-Backend/src/production-batches/production-batches.module.ts index 4bbbc70..7245971 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.module.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { StockModule } from '../stock/stock.module'; import { ProductionBatchesController } from './production-batches.controller'; import { ProductionBatchesService } from './production-batches.service'; @Module({ - imports: [StockModule], + imports: [StockModule, AutomationModule], controllers: [ProductionBatchesController], providers: [ProductionBatchesService], }) diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts index 492a240..1d4cc42 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.service.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -8,7 +8,9 @@ import { import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; +import { RawMaterialKind } from '../../generated/prisma'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; @@ -41,6 +43,7 @@ export class ProductionBatchesService { private readonly auditLog: AuditLogService, private readonly stock: StockService, private readonly permissions: PermissionResolverService, + private readonly automation: AutomationService, ) {} async findAll(filter: ProductionBatchFilter = {}) { @@ -88,6 +91,16 @@ export class ProductionBatchesService { 'Herstellungschargen nur für Artikel der Art "Hergestellte Ware" - Honig wird abgefüllt', ); } + // Generated only after all checks above, so a rejected batch does not + // use up a sequence number. + const lotNumber = + dto.lotNumber ?? + (await this.automation.generateNumber('PRODUCTION_BATCH_NUMBER_FORMAT')); + if (!lotNumber) { + throw new BadRequestException( + 'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Herstellung)" aktivieren)', + ); + } // A duplicate lotNumber surfaces as P2002 and becomes a 409 with a // field-specific message in AllExceptionsFilter. Batch and raw material // consumption are one transaction: a batch never exists without its @@ -96,7 +109,7 @@ export class ProductionBatchesService { const created = await tx.productionBatch.create({ data: { productId: dto.productId, - lotNumber: dto.lotNumber, + lotNumber, date: new Date(dto.date), quantity: dto.quantity, bestBeforeDate: dto.bestBeforeDate @@ -128,7 +141,20 @@ export class ProductionBatchesService { bottlingBatchIds: [], productIds: [batch.productId], }); - return this.findById(batch.id); + const created = await this.findById(batch.id); + await this.checkRawMaterialLow(created.inputs); + return created; + } + + /** Runs the RAW_MATERIAL_LOW rule for every kind this batch consumed. */ + private async checkRawMaterialLow( + inputs: { rawMaterialLot: { kind: RawMaterialKind } }[], + ): Promise { + const kinds = new Set(inputs.map((input) => input.rawMaterialLot.kind)); + for (const kind of kinds) { + const remaining = await this.stock.remainingByRawMaterialKind(kind); + await this.automation.evaluateRawMaterialLow(kind, remaining); + } } async update( diff --git a/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts b/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts index e37a372..bbb658a 100644 --- a/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts +++ b/ApiCore-Backend/src/raw-materials/dto/create-raw-material-lot.dto.ts @@ -16,11 +16,15 @@ export class CreateRawMaterialLotDto { @IsEnum(RawMaterialKind) kind!: RawMaterialKind; - @ApiProperty() + @ApiPropertyOptional({ + description: + 'Leer lassen, wenn die Automatisierung "Chargennummer-Format (Rohstoffe)" aktiv ist', + }) + @IsOptional() @IsString() @IsNotEmpty() @MaxLength(100) - lotNumber!: string; + lotNumber?: string; @ApiProperty({ description: 'Gewinnungsdatum' }) @IsDateString() diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts b/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts index 1d6a8a0..17d4866 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.controller.ts @@ -67,25 +67,23 @@ export class RawMaterialsController { @RequirePermission('raw-material:withdraw') @Idempotent() @Post(':id/withdrawals') - async withdraw( + withdraw( @Param('id') id: string, @Body() dto: WithdrawStockDto, @CurrentUser() actor: AuthenticatedUser, ) { - await this.lots.findById(id); - return this.stock.withdraw({ kind: 'raw', id }, dto, actor.id); + return this.lots.withdraw(id, dto, actor.id); } @RequirePermission('raw-material:withdraw') @Idempotent() @Post(':id/corrections') - async correct( + correct( @Param('id') id: string, @Body() dto: CorrectStockDto, @CurrentUser() actor: AuthenticatedUser, ) { - await this.lots.findById(id); - return this.stock.correct({ kind: 'raw', id }, dto, actor.id); + return this.lots.correct(id, dto, actor.id); } @RequirePermission('raw-material:update') diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.module.ts b/ApiCore-Backend/src/raw-materials/raw-materials.module.ts index 33e2584..98a2879 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.module.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { StockModule } from '../stock/stock.module'; import { RawMaterialsController } from './raw-materials.controller'; import { RawMaterialsService } from './raw-materials.service'; @Module({ - imports: [StockModule], + imports: [StockModule, AutomationModule], controllers: [RawMaterialsController], providers: [RawMaterialsService], }) diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts index 15fa0a9..8f3dc5d 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts @@ -1,12 +1,16 @@ import { + BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; import { RawMaterialKind } from '../../generated/prisma'; +import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; +import { CorrectStockDto } from '../stock/dto/correct-stock.dto'; import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto'; import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto'; @@ -45,6 +49,7 @@ export class RawMaterialsService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly stock: StockService, + private readonly automation: AutomationService, ) {} async findAll(filter: RawMaterialLotFilter = {}) { @@ -73,12 +78,20 @@ export class RawMaterialsService { } async create(dto: CreateRawMaterialLotDto, actingUserId: string) { + const lotNumber = + dto.lotNumber ?? + (await this.automation.generateNumber('RAW_MATERIAL_LOT_NUMBER_FORMAT')); + if (!lotNumber) { + throw new BadRequestException( + 'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Rohstoffe)" aktivieren)', + ); + } // A duplicate lotNumber surfaces as P2002 and becomes a 409 in // AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error. const lot = await this.prisma.rawMaterialLot.create({ data: { kind: dto.kind, - lotNumber: dto.lotNumber, + lotNumber, date: new Date(dto.date), quantity: dto.quantity, apiaryId: dto.apiaryId, @@ -131,6 +144,34 @@ export class RawMaterialsService { return after; } + async withdraw(id: string, dto: WithdrawStockDto, actingUserId: string) { + const lot = await this.findById(id); + const stock = await this.stock.withdraw( + { kind: 'raw', id }, + dto, + actingUserId, + ); + await this.checkLowStock(lot.kind); + return stock; + } + + async correct(id: string, dto: CorrectStockDto, actingUserId: string) { + const lot = await this.findById(id); + const stock = await this.stock.correct( + { kind: 'raw', id }, + dto, + actingUserId, + ); + await this.checkLowStock(lot.kind); + return stock; + } + + /** Runs the RAW_MATERIAL_LOW rule against what is left of the kind. */ + private async checkLowStock(kind: RawMaterialKind): Promise { + const remaining = await this.stock.remainingByRawMaterialKind(kind); + await this.automation.evaluateRawMaterialLow(kind, remaining); + } + /** Only for lots entered by mistake: once anything was booked, it stays. */ async remove(id: string, actingUserId: string) { const before = await this.findById(id); diff --git a/ApiCore-Backend/src/stock/stock.service.ts b/ApiCore-Backend/src/stock/stock.service.ts index 2676a49..2a2d4dd 100644 --- a/ApiCore-Backend/src/stock/stock.service.ts +++ b/ApiCore-Backend/src/stock/stock.service.ts @@ -6,7 +6,11 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { Prisma, WithdrawalReason } from '../../generated/prisma'; +import { + Prisma, + RawMaterialKind, + WithdrawalReason, +} from '../../generated/prisma'; import { allocateReturn, allocateSale, @@ -137,6 +141,18 @@ export class StockService { return this.remainingFor('raw', ids); } + /** Remaining grams of one raw material kind, summed over all its lots. */ + async remainingByRawMaterialKind(kind: RawMaterialKind): Promise { + const lots = await this.prisma.rawMaterialLot.findMany({ + where: { kind }, + select: { id: true }, + }); + const perLot = await this.remainingByRawMaterialLot( + lots.map((lot) => lot.id), + ); + return [...perLot.values()].reduce((sum, grams) => sum + grams, 0); + } + /** Remaining stock per MANUFACTURED article (sum of its batches). */ async remainingByProduct(ids?: string[]): Promise> { const batches = await this.prisma.productionBatch.findMany({ diff --git a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx index dd194f7..273472a 100644 --- a/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/production-batches/new/page.tsx @@ -70,7 +70,7 @@ export default function NewProductionBatchPage() { mutationFn: () => createProductionBatch({ productId, - lotNumber: lotNumber.trim(), + lotNumber: lotNumber.trim() || undefined, date: new Date(date).toISOString(), quantity: Number(quantity), bestBeforeDate: bestBeforeDate @@ -150,7 +150,7 @@ export default function NewProductionBatchPage() { setLotNumber(event.target.value)} /> diff --git a/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx b/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx index fbd86fb..2290415 100644 --- a/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/raw-materials/new/page.tsx @@ -46,7 +46,7 @@ export default function NewRawMaterialPage() { mutationFn: () => createRawMaterialLot({ kind, - lotNumber: lotNumber.trim(), + lotNumber: lotNumber.trim() || undefined, date: new Date(date).toISOString(), quantity: Number(quantity), apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId, @@ -110,7 +110,7 @@ export default function NewRawMaterialPage() { setLotNumber(event.target.value)} /> diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 0fbaaba..450ef58 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -28,6 +28,9 @@ const TRIGGER_LABELS: Record = { BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)", TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung", FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung", + RAW_MATERIAL_LOT_NUMBER_FORMAT: "Chargennummer-Format (Rohstoffe)", + PRODUCTION_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Herstellung)", + RAW_MATERIAL_LOW: "Rohstoff unter Mindestmenge", }; const TRIGGER_DESCRIPTIONS: Record = { @@ -51,13 +54,51 @@ const TRIGGER_DESCRIPTIONS: Record = { "Nach jeder erfassten Behandlung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach Behandlungsende. Die passende Frist hängt vom Mittel ab (Packungsbeilage) – deshalb ab Werk aus und ohne voreingestellte Frist.", FEEDING_FOLLOW_UP: "Nach jeder erfassten Fütterung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach der Fütterung. Ab Werk aus und ohne voreingestellte Frist.", + RAW_MATERIAL_LOT_NUMBER_FORMAT: + "Wenn aktiv, wird die Chargennummer neuer Rohstoff-Chargen (Wachs, Propolis) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.", + PRODUCTION_BATCH_NUMBER_FORMAT: + "Wenn aktiv, wird die Chargennummer neuer Herstellungschargen (Kerzen, Tinkturen, …) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.", + RAW_MATERIAL_LOW: + "Fällt der Gesamtbestand einer Rohstoff-Art nach Verbrauch, Entnahme oder Inventur unter die Mindestmenge, dann Aufgabe zum Nachschub. Leere Mindestmenge = diese Art wird nicht überwacht.", }; const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [ "HONEY_BATCH_NUMBER_FORMAT", "BOTTLING_LOT_NUMBER_FORMAT", + "RAW_MATERIAL_LOT_NUMBER_FORMAT", + "PRODUCTION_BATCH_NUMBER_FORMAT", ]; +const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [ + { kind: "BEESWAX", label: "Mindestmenge Bienenwachs (g)" }, + { kind: "PROPOLIS", label: "Mindestmenge Propolis (g)" }, +]; + +type MinGrams = Partial>; + +function ruleMinGrams(rule: AutomationRule): MinGrams { + const config = rule.config as { minGrams?: Record } | null; + const result: MinGrams = {}; + for (const { kind } of MIN_GRAMS_FIELDS) { + const value = config?.minGrams?.[kind]; + result[kind] = typeof value === "number" ? String(value) : ""; + } + return result; +} + +/** Empty fields drop out; anything else must be whole grams >= 0. */ +function parseMinGrams(input: MinGrams): Record | null { + const result: Record = {}; + for (const { kind } of MIN_GRAMS_FIELDS) { + const raw = (input[kind] ?? "").trim(); + if (raw === "") continue; + const grams = Number(raw); + if (!Number.isInteger(grams) || grams < 0) return null; + result[kind] = grams; + } + return result; +} + function isNumberFormatRule(rule: AutomationRule): boolean { return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType); } @@ -83,6 +124,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) { const [threshold, setThreshold] = useState(ruleThreshold(rule)); const [format, setFormat] = useState(ruleFormat(rule)); const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule)); + const [minGrams, setMinGrams] = useState(ruleMinGrams(rule)); const numberFormatRule = isNumberFormatRule(rule); const mutation = useMutation({ @@ -119,6 +161,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) { if (rule.triggerType === "VARROA_THRESHOLD") { config.thresholdMitesPerDay = Number(threshold) || 10; } + if (rule.triggerType === "RAW_MATERIAL_LOW") { + const parsed = parseMinGrams(minGrams); + if (!parsed) { + toast.error("Mindestmenge bitte als ganze Gramm angeben – oder leer lassen."); + return; + } + config.minGrams = parsed; + } mutation.mutate({ actionTemplate: actionTemplate || undefined, config }); } @@ -194,6 +244,25 @@ function RuleCard({ rule }: { rule: AutomationRule }) { /> ) : null} + {rule.triggerType === "RAW_MATERIAL_LOW" + ? MIN_GRAMS_FIELDS.map(({ kind, label }) => ( +
+ + + setMinGrams((current) => ({ ...current, [kind]: event.target.value })) + } + /> +
+ )) + : null}