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 559 additions and 46 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
TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
}
model AutomationRule {
@@ -1354,14 +1357,31 @@ 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.
sequenceValue Int @default(0)
createdAt DateTime @default(now())
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;
update: jest.Mock;
};
automationSequence: { upsert: jest.Mock };
task: { create: jest.Mock; findFirst: jest.Mock };
};
let auditLog: { record: jest.Mock };
@@ -51,6 +52,7 @@ describe('AutomationService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
automationSequence: { upsert: jest.fn() },
task: {
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
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', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
@@ -407,6 +463,73 @@ describe('AutomationService', () => {
});
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', () => {
@@ -427,7 +550,7 @@ describe('AutomationService', () => {
expect(prisma.automationRule.update).toHaveBeenCalledWith({
where: { id: 'rule-4' },
data: { sequenceValue: 0 },
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
@@ -10,6 +10,7 @@ import {
Inspection,
Feeding,
Prisma,
RawMaterialKind,
StockItem,
SwarmControlCheck,
TaskRelatedType,
@@ -19,6 +20,37 @@ 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<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 {
config: Record<string, unknown>;
actionTemplate: string;
@@ -64,6 +96,23 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
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
// makes sense depends on the product and the season, so there is no
// 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();
return this.prisma.automationRule.findMany({
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);
}
/**
* 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> {
await this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
@@ -326,8 +411,14 @@ 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.
//
// `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(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
triggerType: NumberFormatTrigger,
options: { scope?: string; vars?: Record<string, string> } = {},
): Promise<string | null> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
@@ -341,14 +432,51 @@ export class AutomationService {
if (!format) {
return null;
}
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
// batch/bottling creation, no explicit transaction/lock needed.
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
// batch/bottling creation, no explicit transaction/lock needed.
private async nextSharedSequence(
triggerType: NumberFormatTrigger,
): Promise<number> {
const updated = await this.prisma.automationRule.update({
where: { triggerType },
data: { sequenceValue: { increment: 1 } },
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(
@@ -356,9 +484,10 @@ export class AutomationService {
actingUserId: string,
): Promise<AutomationRule> {
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({
where: { id },
data: { sequenceValue: 0 },
data: { sequenceValue: 0, sequences: { deleteMany: {} } },
});
await this.auditLog.record({
entityType: 'AutomationRule',
@@ -9,17 +9,24 @@ const MAX_SEQUENCE_PAD_WIDTH = 20;
// {DD} day (01-31), {SEQ} running sequence unpadded, {SEQ:N} sequence
// zero-padded to N digits. Example matching the lastenheft's own sample
// ("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(
format: string,
sequence: number,
date: Date,
vars: Record<string, string> = {},
): string {
const yyyy = String(date.getFullYear());
const yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).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(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm)
@@ -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()
@@ -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],
})
@@ -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<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(
@@ -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()
@@ -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')
@@ -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],
})
@@ -1,12 +1,19 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.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 { 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 +52,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 +81,23 @@ export class RawMaterialsService {
}
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
// 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 +150,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<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. */
async remove(id: string, actingUserId: string) {
const before = await this.findById(id);
+17 -1
View File
@@ -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<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). */
async remainingByProduct(ids?: string[]): Promise<Map<string, number>> {
const batches = await this.prisma.productionBatch.findMany({
@@ -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() {
<Label htmlFor="pb-lot">Chargennummer</Label>
<Input
id="pb-lot"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
onChange={(event) => setLotNumber(event.target.value)}
/>
@@ -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() {
<Label htmlFor="raw-lot">Chargennummer</Label>
<Input
id="raw-lot"
required
placeholder="Automatisch, falls Automatisierung aktiv"
value={lotNumber}
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)",
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<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.",
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)" },
];
// 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 {
return NUMBER_FORMAT_TRIGGER_TYPES.includes(rule.triggerType);
}
@@ -83,7 +130,13 @@ 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<MinGrams>(ruleMinGrams(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({
mutationFn: (input: { active?: boolean; config?: Record<string, unknown>; actionTemplate?: string }) =>
@@ -119,6 +172,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 });
}
@@ -144,18 +205,43 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
<p className="text-xs text-muted-foreground">
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
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 className="text-xs text-muted-foreground">
Aktueller Zähler: {rule.sequenceValue}
</p>
{rule.active ? (
<p className="text-sm font-medium">
Beispiel für die nächste Nummer:{" "}
<span className="font-mono">
{formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())}
</span>
</p>
) : null}
{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">
Aktueller Zähler: {rule.sequenceValue}
</p>
{rule.active ? (
<p className="text-sm font-medium">
Beispiel für die nächste Nummer:{" "}
<span className="font-mono">
{formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())}
</span>
</p>
) : null}
</>
)}
</div>
) : (
<div className="flex flex-col gap-2">
@@ -194,6 +280,25 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
/>
</div>
) : 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">
<Button
type="button"
@@ -9,13 +9,22 @@
// every keystroke and freeze the settings page.
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 yy = yyyy.slice(-2);
const mm = String(date.getMonth() + 1).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(/\{YY\}/g, yy)
.replace(/\{MM\}/g, mm)
+6 -1
View File
@@ -8,7 +8,10 @@ export type AutomationTriggerType =
| "HONEY_BATCH_NUMBER_FORMAT"
| "BOTTLING_LOT_NUMBER_FORMAT"
| "TREATMENT_FOLLOW_UP"
| "FEEDING_FOLLOW_UP";
| "FEEDING_FOLLOW_UP"
| "RAW_MATERIAL_LOT_NUMBER_FORMAT"
| "PRODUCTION_BATCH_NUMBER_FORMAT"
| "RAW_MATERIAL_LOW";
export interface AutomationRule {
id: string;
@@ -17,6 +20,8 @@ export interface AutomationRule {
config: Record<string, unknown> | null;
actionTemplate: string | null;
sequenceValue: number;
/** Separate counters per scope, e.g. per raw material kind ({ART}). */
sequences?: { scope: string; value: number }[];
createdAt: string;
updatedAt: string;
}
@@ -23,7 +23,8 @@ export interface ProductionBatch {
export interface CreateProductionBatchInput {
productId: string;
lotNumber: string;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string;
quantity: number;
bestBeforeDate?: string;
@@ -28,7 +28,8 @@ export interface RawMaterialLot {
export interface CreateRawMaterialLotInput {
kind: RawMaterialKind;
lotNumber: string;
/** Leer = automatisch, falls die Automatisierung aktiv ist. */
lotNumber?: string;
date: string;
quantity: number;
apiaryId?: string;