Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 cfd3a187af feat(automation): separate lot number counters per raw material kind
Backend Image / build-and-push (push) Successful in 2m15s
Frontend Image / build-and-push (push) Successful in 2m3s
With {ART} in the raw material number format (default {ART}-{YYYY}-{SEQ:4}),
beeswax and propolis each count on their own: WA-2026-0001, PR-2026-0001.
Counters live in the new AutomationSequence table (one row per rule and
scope). Without {ART} the rule keeps its single shared counter, since
separate counters behind one prefix would hand out duplicate numbers.
Resetting a rule clears all of its counters.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 19:33:10 +02:00
Nick SzaunigandClaude Opus 5.5 5408711b2d 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]>
2026-10-07 16:01:33 +02:00
21 changed files with 546 additions and 33 deletions

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';
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "AutomationSequence" (
"triggerType" "AutomationTriggerType" NOT NULL,
"scope" TEXT NOT NULL,
"value" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
);
-- AddForeignKey
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
+22 -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,14 +1357,31 @@ 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.
sequenceValue Int @default(0) sequenceValue Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sequences AutomationSequence[]
}
// Separate counters of one number-format rule, one per scope - e.g. per raw
// material kind when the format contains {ART}, so wax and propolis each
// count WA-2026-0001, WA-2026-0002, ... and PR-2026-0001, ... on their own.
// Without {ART} the rule keeps using AutomationRule.sequenceValue: separate
// counters behind an identical prefix would produce duplicate numbers.
model AutomationSequence {
triggerType AutomationTriggerType
rule AutomationRule @relation(fields: [triggerType], references: [triggerType], onDelete: Cascade)
scope String
value Int @default(0)
updatedAt DateTime @updatedAt
@@id([triggerType, scope])
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -15,6 +15,7 @@ describe('AutomationService', () => {
findUnique: jest.Mock; findUnique: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
automationSequence: { upsert: jest.Mock };
task: { create: jest.Mock; findFirst: jest.Mock }; task: { create: jest.Mock; findFirst: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -51,6 +52,7 @@ describe('AutomationService', () => {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
automationSequence: { upsert: jest.fn() },
task: { task: {
create: jest.fn().mockResolvedValue({ id: 'task-1' }), create: jest.fn().mockResolvedValue({ id: 'task-1' }),
findFirst: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null),
@@ -338,6 +340,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);
@@ -407,6 +463,73 @@ describe('AutomationService', () => {
}); });
expect(result).toBe(`WH-${new Date().getFullYear()}-0007`); expect(result).toBe(`WH-${new Date().getFullYear()}-0007`);
}); });
const rawFormatRule = {
id: 'rule-8',
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
active: true,
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
sequenceValue: 0,
};
it('counts per scope when the format tells the scopes apart', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
prisma.automationSequence.upsert.mockResolvedValue({ value: 3 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'PROPOLIS', vars: { ART: 'PR' } },
);
expect(prisma.automationSequence.upsert).toHaveBeenCalledWith({
where: {
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'PROPOLIS',
},
},
create: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'PROPOLIS',
value: 1,
},
update: { value: { increment: 1 } },
select: { value: true },
});
expect(prisma.automationRule.update).not.toHaveBeenCalled();
expect(result).toBe(`PR-${new Date().getFullYear()}-0003`);
});
it('keeps one shared counter when the format has no {ART}', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
...rawFormatRule,
config: { format: 'RS-{YYYY}-{SEQ:4}' },
});
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 5 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
);
expect(prisma.automationSequence.upsert).not.toHaveBeenCalled();
expect(result).toBe(`RS-${new Date().getFullYear()}-0005`);
});
it('retries once when two first uses of a scope collide', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawFormatRule);
prisma.automationSequence.upsert
.mockRejectedValueOnce({ code: 'P2002' })
.mockResolvedValueOnce({ value: 2 });
const result = await service.generateNumber(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
{ scope: 'BEESWAX', vars: { ART: 'WA' } },
);
expect(prisma.automationSequence.upsert).toHaveBeenCalledTimes(2);
expect(result).toBe(`WA-${new Date().getFullYear()}-0002`);
});
}); });
describe('resetSequence', () => { describe('resetSequence', () => {
@@ -427,7 +550,7 @@ describe('AutomationService', () => {
expect(prisma.automationRule.update).toHaveBeenCalledWith({ expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { id: 'rule-4' }, where: { id: 'rule-4' },
data: { sequenceValue: 0 }, data: { sequenceValue: 0, sequences: { deleteMany: {} } },
}); });
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -10,6 +10,7 @@ import {
Inspection, Inspection,
Feeding, Feeding,
Prisma, Prisma,
RawMaterialKind,
StockItem, StockItem,
SwarmControlCheck, SwarmControlCheck,
TaskRelatedType, TaskRelatedType,
@@ -19,6 +20,37 @@ 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',
};
/** What {ART} turns into in the raw material number format. */
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
BEESWAX: 'WA',
PROPOLIS: 'PR',
};
export type AutomationRuleWithSequences = AutomationRule & {
sequences: { scope: string; value: number }[];
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002'
);
}
interface RuleDefaults { interface RuleDefaults {
config: Record<string, unknown>; config: Record<string, unknown>;
actionTemplate: string; actionTemplate: string;
@@ -64,6 +96,23 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' }, config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '', actionTemplate: '',
}, },
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{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
@@ -140,10 +189,16 @@ export class AutomationService {
} }
} }
async findAll(): Promise<AutomationRule[]> { async findAll(): Promise<AutomationRuleWithSequences[]> {
await this.ensureDefaults(); await this.ensureDefaults();
return this.prisma.automationRule.findMany({ return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' }, orderBy: { triggerType: 'asc' },
include: {
sequences: {
select: { scope: true, value: true },
orderBy: { scope: 'asc' },
},
},
}); });
} }
@@ -286,6 +341,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',
@@ -326,8 +411,14 @@ 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.
//
// `scope` asks for a counter of its own (e.g. per raw material kind). It
// only applies when the format contains a placeholder from `vars` that
// tells the scopes apart - otherwise two scopes would hand out the same
// number, so the rule falls back to its one shared counter.
async generateNumber( async generateNumber(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT', triggerType: NumberFormatTrigger,
options: { scope?: string; vars?: Record<string, string> } = {},
): 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({
@@ -341,14 +432,51 @@ export class AutomationService {
if (!format) { if (!format) {
return null; return null;
} }
const vars = options.vars ?? {};
const scoped =
options.scope !== undefined &&
Object.keys(vars).some((key) => format.includes(`{${key}}`));
const sequence = scoped
? await this.nextScopedSequence(triggerType, options.scope!)
: await this.nextSharedSequence(triggerType);
return formatGeneratedNumber(format, sequence, new Date(), vars);
}
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent // Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
// batch/bottling creation, no explicit transaction/lock needed. // batch/bottling creation, no explicit transaction/lock needed.
private async nextSharedSequence(
triggerType: NumberFormatTrigger,
): Promise<number> {
const updated = await this.prisma.automationRule.update({ const updated = await this.prisma.automationRule.update({
where: { triggerType }, where: { triggerType },
data: { sequenceValue: { increment: 1 } }, data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true }, select: { sequenceValue: true },
}); });
return formatGeneratedNumber(format, updated.sequenceValue, new Date()); return updated.sequenceValue;
}
// Same atomic increment on the scope's own row, created on first use. Two
// first uses at once can both try the insert; the loser gets P2002 and
// simply increments the row the winner created.
private async nextScopedSequence(
triggerType: NumberFormatTrigger,
scope: string,
): Promise<number> {
const upsert = () =>
this.prisma.automationSequence.upsert({
where: { triggerType_scope: { triggerType, scope } },
create: { triggerType, scope, value: 1 },
update: { value: { increment: 1 } },
select: { value: true },
});
try {
return (await upsert()).value;
} catch (error) {
if (isUniqueViolation(error)) {
return (await upsert()).value;
}
throw error;
}
} }
async resetSequence( async resetSequence(
@@ -356,9 +484,10 @@ export class AutomationService {
actingUserId: string, actingUserId: string,
): Promise<AutomationRule> { ): Promise<AutomationRule> {
const before = await this.findById(id); const before = await this.findById(id);
// Resets the shared counter and every per-scope counter of the rule.
const rule = await this.prisma.automationRule.update({ const rule = await this.prisma.automationRule.update({
where: { id }, where: { id },
data: { sequenceValue: 0 }, data: { sequenceValue: 0, sequences: { deleteMany: {} } },
}); });
await this.auditLog.record({ await this.auditLog.record({
entityType: 'AutomationRule', entityType: 'AutomationRule',
@@ -9,17 +9,24 @@ const MAX_SEQUENCE_PAD_WIDTH = 20;
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence // {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
// zero-padded to N digits. Example matching the lastenheft's own sample // zero-padded to N digits. Example matching the lastenheft's own sample
// ("WH-2026-0001"): format "WH-{YYYY}-{SEQ:4}". // ("WH-2026-0001"): format "WH-{YYYY}-{SEQ:4}".
// `vars` fills rule-specific placeholders, e.g. { ART: 'WA' } for {ART} in
// the raw material format; a placeholder without a value stays as typed.
export function formatGeneratedNumber( export function formatGeneratedNumber(
format: string, format: string,
sequence: number, sequence: number,
date: Date, date: Date,
vars: Record<string, string> = {},
): string { ): string {
const yyyy = String(date.getFullYear()); const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2); const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0'); const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0');
return format const withVars = Object.entries(vars).reduce(
(result, [key, value]) => result.split(`{${key}}`).join(value),
format,
);
return withVars
.replace(/\{YYYY\}/g, yyyy) .replace(/\{YYYY\}/g, yyyy)
.replace(/\{YY\}/g, yy) .replace(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm) .replace(/\{MM\}/g, mm)
@@ -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,19 @@
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,
RAW_MATERIAL_KIND_CODES,
} 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 +52,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 +81,23 @@ 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', {
scope: dto.kind,
vars: { ART: RAW_MATERIAL_KIND_CODES[dto.kind] },
}));
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 +150,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,57 @@ 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)" },
];
// Same codes as RAW_MATERIAL_KIND_CODES in the backend.
const RAW_KIND_CODES: { kind: "BEESWAX" | "PROPOLIS"; code: string; label: string }[] = [
{ kind: "BEESWAX", code: "WA", label: "Bienenwachs" },
{ kind: "PROPOLIS", code: "PR", label: "Propolis" },
];
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,7 +130,13 @@ 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 rawFormatRule = rule.triggerType === "RAW_MATERIAL_LOT_NUMBER_FORMAT";
// Mirrors the backend: separate counters only while {ART} is in the format.
const perKind = rawFormatRule && format.includes("{ART}");
const kindSequence = (kind: string) =>
rule.sequences?.find((sequence) => sequence.scope === kind)?.value ?? 0;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) => mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) =>
@@ -119,6 +172,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 });
} }
@@ -144,7 +205,30 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
Nummer, {"{SEQ:4}"} mit führenden Nullen auf 4 Stellen. Nummer, {"{SEQ:4}"} mit führenden Nullen auf 4 Stellen.
{rawFormatRule
? ` ${"{ART}"} Kürzel der Art (WA = Bienenwachs, PR = Propolis) – mit ${"{ART}"} zählt jede Art für sich, ohne gibt es einen gemeinsamen Zähler.`
: null}
</p> </p>
{perKind ? (
<ul className="flex flex-col gap-1 text-xs text-muted-foreground">
{RAW_KIND_CODES.map(({ kind, code, label }) => (
<li key={kind}>
Zähler {label}: {kindSequence(kind)}
{rule.active ? (
<>
{" "}– nächste Nummer{" "}
<span className="font-mono text-foreground">
{formatGeneratedNumber(format, kindSequence(kind) + 1, new Date(), {
ART: code,
})}
</span>
</>
) : null}
</li>
))}
</ul>
) : (
<>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Aktueller Zähler: {rule.sequenceValue} Aktueller Zähler: {rule.sequenceValue}
</p> </p>
@@ -156,6 +240,8 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
</span> </span>
</p> </p>
) : null} ) : null}
</>
)}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -194,6 +280,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"
@@ -9,13 +9,22 @@
// every keystroke and freeze the settings page. // every keystroke and freeze the settings page.
const MAX_SEQUENCE_PAD_WIDTH = 20; const MAX_SEQUENCE_PAD_WIDTH = 20;
export function formatGeneratedNumber(format: string, sequence: number, date: Date): string { export function formatGeneratedNumber(
format: string,
sequence: number,
date: Date,
vars: Record<string, string> = {},
): string {
const yyyy = String(date.getFullYear()); const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2); const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, "0"); const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0");
return format const withVars = Object.entries(vars).reduce(
(result, [key, value]) => result.split(`{${key}}`).join(value),
format,
);
return withVars
.replace(/\{YYYY\}/g, yyyy) .replace(/\{YYYY\}/g, yyyy)
.replace(/\{YY\}/g, yy) .replace(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm) .replace(/\{MM\}/g, mm)
+6 -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;
@@ -17,6 +20,8 @@ export interface AutomationRule {
config: Record<string, unknown> | null; config: Record<string, unknown> | null;
actionTemplate: string | null; actionTemplate: string | null;
sequenceValue: number; sequenceValue: number;
/** Separate counters per scope, e.g. per raw material kind ({ART}). */
sequences?: { scope: string; value: number }[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: 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;