feat(automation): number formats for raw material and production batches, raw material minimum

- RAW_MATERIAL_LOT_NUMBER_FORMAT / PRODUCTION_BATCH_NUMBER_FORMAT fill in the
  lot number when it is left empty, like honey batches and bottlings.
- RAW_MATERIAL_LOW: task when the total of a raw material kind drops below
  config.minGrams after consumption, withdrawal or inventory count.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-07 16:01:33 +02:00
1 parent fc887d8431
commit 5408711b2d
18 files changed
+311 -26

No files matched your search

@@ -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';
+5 -2
View File
@@ -1346,6 +1346,9 @@ enum AutomationTriggerType {
BOTTLING_LOT_NUMBER_FORMAT BOTTLING_LOT_NUMBER_FORMAT
TREATMENT_FOLLOW_UP TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP FEEDING_FOLLOW_UP
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
} }
model AutomationRule { model AutomationRule {
@@ -1354,8 +1357,8 @@ model AutomationRule {
active Boolean @default(true) active Boolean @default(true)
config Json? config Json?
actionTemplate String? actionTemplate String?
// Running counter for the number-format rules (HONEY_BATCH_NUMBER_FORMAT / // Running counter for the number-format rules (*_NUMBER_FORMAT) - unused
// BOTTLING_LOT_NUMBER_FORMAT) - unused by the threshold-style rules. Kept as // by the threshold-style rules. Kept as
// a dedicated column (not inside `config`) so it can be incremented with a // a dedicated column (not inside `config`) so it can be incremented with a
// single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`, // single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`,
// race-safe under concurrent batch/bottling creation. // race-safe under concurrent batch/bottling creation.
@@ -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', () => { describe('update', () => {
it('records an audit log entry with before/after data', async () => { it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule); prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
@@ -10,6 +10,7 @@ import {
Inspection, Inspection,
Feeding, Feeding,
Prisma, Prisma,
RawMaterialKind,
StockItem, StockItem,
SwarmControlCheck, SwarmControlCheck,
TaskRelatedType, TaskRelatedType,
@@ -19,6 +20,18 @@ import {
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format'; 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<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
interface RuleDefaults { interface RuleDefaults {
config: Record<string, unknown>; config: Record<string, unknown>;
actionTemplate: string; actionTemplate: string;
@@ -64,6 +77,22 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' }, config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '', 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 // 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 // makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets // 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); 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<void> {
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<Record<RawMaterialKind, unknown>>;
} | 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<void> { async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateFollowUp( await this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP', 'TREATMENT_FOLLOW_UP',
@@ -327,7 +386,7 @@ export class AutomationService {
// Returns null (never throws) when the rule is inactive or has no format // Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry. // configured, so the caller can fall back to requiring manual entry.
async generateNumber( async generateNumber(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT', triggerType: NumberFormatTrigger,
): Promise<string | null> { ): Promise<string | null> {
await this.ensureDefaults(); await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({ const rule = await this.prisma.automationRule.findUnique({
@@ -30,11 +30,15 @@ export class CreateProductionBatchDto {
@IsString() @IsString()
productId!: string; productId!: string;
@ApiProperty() @ApiPropertyOptional({
description:
'Leer lassen, wenn die Automatisierung "Chargennummer-Format (Herstellung)" aktiv ist',
})
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(100) @MaxLength(100)
lotNumber!: string; lotNumber?: string;
@ApiProperty({ description: 'Herstelldatum' }) @ApiProperty({ description: 'Herstelldatum' })
@IsDateString() @IsDateString()
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { ProductionBatchesController } from './production-batches.controller'; import { ProductionBatchesController } from './production-batches.controller';
import { ProductionBatchesService } from './production-batches.service'; import { ProductionBatchesService } from './production-batches.service';
@Module({ @Module({
imports: [StockModule], imports: [StockModule, AutomationModule],
controllers: [ProductionBatchesController], controllers: [ProductionBatchesController],
providers: [ProductionBatchesService], providers: [ProductionBatchesService],
}) })
@@ -8,7 +8,9 @@ import {
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma';
import { CreateProductionBatchDto } from './dto/create-production-batch.dto'; import { CreateProductionBatchDto } from './dto/create-production-batch.dto';
import { UpdateProductionBatchDto } from './dto/update-production-batch.dto'; import { UpdateProductionBatchDto } from './dto/update-production-batch.dto';
@@ -41,6 +43,7 @@ export class ProductionBatchesService {
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly stock: StockService, private readonly stock: StockService,
private readonly permissions: PermissionResolverService, private readonly permissions: PermissionResolverService,
private readonly automation: AutomationService,
) {} ) {}
async findAll(filter: ProductionBatchFilter = {}) { async findAll(filter: ProductionBatchFilter = {}) {
@@ -88,6 +91,16 @@ export class ProductionBatchesService {
'Herstellungschargen nur für Artikel der Art "Hergestellte Ware" - Honig wird abgefüllt', '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 // A duplicate lotNumber surfaces as P2002 and becomes a 409 with a
// field-specific message in AllExceptionsFilter. Batch and raw material // field-specific message in AllExceptionsFilter. Batch and raw material
// consumption are one transaction: a batch never exists without its // consumption are one transaction: a batch never exists without its
@@ -96,7 +109,7 @@ export class ProductionBatchesService {
const created = await tx.productionBatch.create({ const created = await tx.productionBatch.create({
data: { data: {
productId: dto.productId, productId: dto.productId,
lotNumber: dto.lotNumber, lotNumber,
date: new Date(dto.date), date: new Date(dto.date),
quantity: dto.quantity, quantity: dto.quantity,
bestBeforeDate: dto.bestBeforeDate bestBeforeDate: dto.bestBeforeDate
@@ -128,7 +141,20 @@ export class ProductionBatchesService {
bottlingBatchIds: [], bottlingBatchIds: [],
productIds: [batch.productId], 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<void> {
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( async update(
@@ -16,11 +16,15 @@ export class CreateRawMaterialLotDto {
@IsEnum(RawMaterialKind) @IsEnum(RawMaterialKind)
kind!: RawMaterialKind; kind!: RawMaterialKind;
@ApiProperty() @ApiPropertyOptional({
description:
'Leer lassen, wenn die Automatisierung "Chargennummer-Format (Rohstoffe)" aktiv ist',
})
@IsOptional()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(100) @MaxLength(100)
lotNumber!: string; lotNumber?: string;
@ApiProperty({ description: 'Gewinnungsdatum' }) @ApiProperty({ description: 'Gewinnungsdatum' })
@IsDateString() @IsDateString()
@@ -67,25 +67,23 @@ export class RawMaterialsController {
@RequirePermission('raw-material:withdraw') @RequirePermission('raw-material:withdraw')
@Idempotent() @Idempotent()
@Post(':id/withdrawals') @Post(':id/withdrawals')
async withdraw( withdraw(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: WithdrawStockDto, @Body() dto: WithdrawStockDto,
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
) { ) {
await this.lots.findById(id); return this.lots.withdraw(id, dto, actor.id);
return this.stock.withdraw({ kind: 'raw', id }, dto, actor.id);
} }
@RequirePermission('raw-material:withdraw') @RequirePermission('raw-material:withdraw')
@Idempotent() @Idempotent()
@Post(':id/corrections') @Post(':id/corrections')
async correct( correct(
@Param('id') id: string, @Param('id') id: string,
@Body() dto: CorrectStockDto, @Body() dto: CorrectStockDto,
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
) { ) {
await this.lots.findById(id); return this.lots.correct(id, dto, actor.id);
return this.stock.correct({ kind: 'raw', id }, dto, actor.id);
} }
@RequirePermission('raw-material:update') @RequirePermission('raw-material:update')
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { RawMaterialsController } from './raw-materials.controller'; import { RawMaterialsController } from './raw-materials.controller';
import { RawMaterialsService } from './raw-materials.service'; import { RawMaterialsService } from './raw-materials.service';
@Module({ @Module({
imports: [StockModule], imports: [StockModule, AutomationModule],
controllers: [RawMaterialsController], controllers: [RawMaterialsController],
providers: [RawMaterialsService], providers: [RawMaterialsService],
}) })
@@ -1,12 +1,16 @@
import { import {
BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service'; import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma'; 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 { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
import { UpdateRawMaterialLotDto } from './dto/update-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 prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly stock: StockService, private readonly stock: StockService,
private readonly automation: AutomationService,
) {} ) {}
async findAll(filter: RawMaterialLotFilter = {}) { async findAll(filter: RawMaterialLotFilter = {}) {
@@ -73,12 +78,20 @@ export class RawMaterialsService {
} }
async create(dto: CreateRawMaterialLotDto, actingUserId: string) { 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 // A duplicate lotNumber surfaces as P2002 and becomes a 409 in
// AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error. // AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error.
const lot = await this.prisma.rawMaterialLot.create({ const lot = await this.prisma.rawMaterialLot.create({
data: { data: {
kind: dto.kind, kind: dto.kind,
lotNumber: dto.lotNumber, lotNumber,
date: new Date(dto.date), date: new Date(dto.date),
quantity: dto.quantity, quantity: dto.quantity,
apiaryId: dto.apiaryId, apiaryId: dto.apiaryId,
@@ -131,6 +144,34 @@ export class RawMaterialsService {
return after; 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<void> {
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. */ /** Only for lots entered by mistake: once anything was booked, it stays. */
async remove(id: string, actingUserId: string) { async remove(id: string, actingUserId: string) {
const before = await this.findById(id); const before = await this.findById(id);
+17 -1
View File
@@ -6,7 +6,11 @@ import {
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { Prisma, WithdrawalReason } from '../../generated/prisma'; import {
Prisma,
RawMaterialKind,
WithdrawalReason,
} from '../../generated/prisma';
import { import {
allocateReturn, allocateReturn,
allocateSale, allocateSale,
@@ -137,6 +141,18 @@ export class StockService {
return this.remainingFor('raw', ids); return this.remainingFor('raw', ids);
} }
/** Remaining grams of one raw material kind, summed over all its lots. */
async remainingByRawMaterialKind(kind: RawMaterialKind): Promise<number> {
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). */ /** Remaining stock per MANUFACTURED article (sum of its batches). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> { async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({ const batches = await this.prisma.productionBatch.findMany({
@@ -70,7 +70,7 @@ export default function NewProductionBatchPage() {
mutationFn: () => mutationFn: () =>
createProductionBatch({ createProductionBatch({
productId, productId,
lotNumber: lotNumber.trim(), lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(), date: new Date(date).toISOString(),
quantity: Number(quantity), quantity: Number(quantity),
bestBeforeDate: bestBeforeDate bestBeforeDate: bestBeforeDate
@@ -150,7 +150,7 @@ export default function NewProductionBatchPage() {
<Label htmlFor="pb-lot">Chargennummer</Label> <Label htmlFor="pb-lot">Chargennummer</Label>
<Input <Input
id="pb-lot" id="pb-lot"
required placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber} value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)} onChange={(event) => setLotNumber(event.target.value)}
/> />
@@ -46,7 +46,7 @@ export default function NewRawMaterialPage() {
mutationFn: () => mutationFn: () =>
createRawMaterialLot({ createRawMaterialLot({
kind, kind,
lotNumber: lotNumber.trim(), lotNumber: lotNumber.trim() || undefined,
date: new Date(date).toISOString(), date: new Date(date).toISOString(),
quantity: Number(quantity), quantity: Number(quantity),
apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId, apiaryId: apiaryId === NO_APIARY ? undefined : apiaryId,
@@ -110,7 +110,7 @@ export default function NewRawMaterialPage() {
<Label htmlFor="raw-lot">Chargennummer</Label> <Label htmlFor="raw-lot">Chargennummer</Label>
<Input <Input
id="raw-lot" id="raw-lot"
required placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber} value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)} onChange={(event) => setLotNumber(event.target.value)}
/> />
@@ -28,6 +28,9 @@ const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)", BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung", TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung",
FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung", 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<AutomationTriggerType, string> = { const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
@@ -51,13 +54,51 @@ const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
"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.", "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: 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.", "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[] = [ const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
"HONEY_BATCH_NUMBER_FORMAT", "HONEY_BATCH_NUMBER_FORMAT",
"BOTTLING_LOT_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<Record<"BEESWAX" | "PROPOLIS", string>>;
function ruleMinGrams(rule: AutomationRule): MinGrams {
const config = rule.config as { minGrams?: Record<string, unknown> } | 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<string, number> | null {
const result: Record<string, number> = {};
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 { function isNumberFormatRule(rule: AutomationRule): boolean {
return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType); return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType);
} }
@@ -83,6 +124,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const [threshold, setThreshold] = useState(ruleThreshold(rule)); const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [format, setFormat] = useState(ruleFormat(rule)); const [format, setFormat] = useState(ruleFormat(rule));
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule)); const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
const numberFormatRule = isNumberFormatRule(rule); const numberFormatRule = isNumberFormatRule(rule);
const mutation = useMutation({ const mutation = useMutation({
@@ -119,6 +161,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
if (rule.triggerType === "VARROA_THRESHOLD") { if (rule.triggerType === "VARROA_THRESHOLD") {
config.thresholdMitesPerDay = Number(threshold) || 10; 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 }); mutation.mutate({ actionTemplate: actionTemplate || undefined, config });
} }
@@ -194,6 +244,25 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
/> />
</div> </div>
) : null} ) : null}
{rule.triggerType === "RAW_MATERIAL_LOW"
? MIN_GRAMS_FIELDS.map(({ kind, label }) => (
<div key={kind} className="flex flex-col gap-2">
<Label htmlFor={`min-${kind}-${rule.id}`}>{label}</Label>
<Input
id={`min-${kind}-${rule.id}`}
type="number"
inputMode="numeric"
min={0}
step={1}
placeholder="leer = nicht überwachen"
value={minGrams[kind] ?? ""}
onChange={(event) =>
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
}
/>
</div>
))
: null}
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button <Button
type="button" type="button"
+4 -1
View File
@@ -8,7 +8,10 @@ export type AutomationTriggerType =
| "HONEY_BATCH_NUMBER_FORMAT" | "HONEY_BATCH_NUMBER_FORMAT"
| "BOTTLING_LOT_NUMBER_FORMAT" | "BOTTLING_LOT_NUMBER_FORMAT"
| "TREATMENT_FOLLOW_UP" | "TREATMENT_FOLLOW_UP"
| "FEEDING_FOLLOW_UP"; | "FEEDING_FOLLOW_UP"
| "RAW_MATERIAL_LOT_NUMBER_FORMAT"
| "PRODUCTION_BATCH_NUMBER_FORMAT"
| "RAW_MATERIAL_LOW";
export interface AutomationRule { export interface AutomationRule {
id: string; id: string;
@@ -23,7 +23,8 @@ export interface ProductionBatch {
export interface CreateProductionBatchInput { export interface CreateProductionBatchInput {
productId: string; productId: string;
lotNumber: string; /** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string; date: string;
quantity: number; quantity: number;
bestBeforeDate?: string; bestBeforeDate?: string;
@@ -28,7 +28,8 @@ export interface RawMaterialLot {
export interface CreateRawMaterialLotInput { export interface CreateRawMaterialLotInput {
kind: RawMaterialKind; kind: RawMaterialKind;
lotNumber: string; /** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string; date: string;
quantity: number; quantity: number;
apiaryId?: string; apiaryId?: string;