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
TREATMENT_FOLLOW_UP
FEEDING_FOLLOW_UP
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
}
model AutomationRule {
@@ -1354,8 +1357,8 @@ model AutomationRule {
active Boolean @default(true)
config Json?
actionTemplate String?
// Running counter for the number-format rules (HONEY_BATCH_NUMBER_FORMAT /
// BOTTLING_LOT_NUMBER_FORMAT) - unused by the threshold-style rules. Kept as
// Running counter for the number-format rules (*_NUMBER_FORMAT) - unused
// by the threshold-style rules. Kept as
// a dedicated column (not inside `config`) so it can be incremented with a
// single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`,
// race-safe under concurrent batch/bottling creation.
@@ -338,6 +338,60 @@ describe('AutomationService', () => {
});
});
describe('evaluateRawMaterialLow', () => {
const rawRule = {
id: 'rule-7',
triggerType: 'RAW_MATERIAL_LOW',
active: true,
config: { minGrams: { BEESWAX: 1000 } },
actionTemplate: 'Rohstoff unter Mindestmenge',
};
it('creates one task per kind when the kind drops below its minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('BEESWAX', 400);
const [createArgs] = prisma.task.create.mock.calls[0] as [unknown];
expect(createArgs).toMatchObject({
data: {
relatedType: 'GENERAL',
relatedId: 'BEESWAX',
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
description:
'Bienenwachs: noch 400 g auf Lager, Mindestmenge 1000 g.',
},
});
});
it('does not trigger at or above the minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('BEESWAX', 1000);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('does not trigger for a kind without a minimum', async () => {
prisma.automationRule.findUnique.mockResolvedValue(rawRule);
await service.evaluateRawMaterialLow('PROPOLIS', 0);
expect(prisma.task.create).not.toHaveBeenCalled();
});
it('does not trigger when the rule is inactive', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
...rawRule,
active: false,
});
await service.evaluateRawMaterialLow('BEESWAX', 0);
expect(prisma.task.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
@@ -10,6 +10,7 @@ import {
Inspection,
Feeding,
Prisma,
RawMaterialKind,
StockItem,
SwarmControlCheck,
TaskRelatedType,
@@ -19,6 +20,18 @@ import {
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
/** The rules that fill in a batch/lot number instead of creating tasks. */
export type NumberFormatTrigger =
| 'HONEY_BATCH_NUMBER_FORMAT'
| 'BOTTLING_LOT_NUMBER_FORMAT'
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
@@ -64,6 +77,22 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: 'RS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
@@ -286,6 +315,36 @@ export class AutomationService {
await this.triggerAction(rule, 'STORAGE', item.id);
}
/**
* After raw material left its lots (consumption, withdrawal, inventory):
* compares what is left of the kind across all lots with config.minGrams.
* The task is about the kind, not one lot - a new lot of the same kind is
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
*/
async evaluateRawMaterialLow(
kind: RawMaterialKind,
remainingGrams: number,
): Promise<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',
@@ -327,7 +386,7 @@ export class AutomationService {
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
async generateNumber(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
triggerType: NumberFormatTrigger,
): Promise<string | null> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
@@ -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,16 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import { RawMaterialKind } from '../../generated/prisma';
import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
import { CorrectStockDto } from '../stock/dto/correct-stock.dto';
import { CreateRawMaterialLotDto } from './dto/create-raw-material-lot.dto';
import { UpdateRawMaterialLotDto } from './dto/update-raw-material-lot.dto';
@@ -45,6 +49,7 @@ export class RawMaterialsService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly stock: StockService,
private readonly automation: AutomationService,
) {}
async findAll(filter: RawMaterialLotFilter = {}) {
@@ -73,12 +78,20 @@ export class RawMaterialsService {
}
async create(dto: CreateRawMaterialLotDto, actingUserId: string) {
const lotNumber =
dto.lotNumber ??
(await this.automation.generateNumber('RAW_MATERIAL_LOT_NUMBER_FORMAT'));
if (!lotNumber) {
throw new BadRequestException(
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Rohstoffe)" aktivieren)',
);
}
// A duplicate lotNumber surfaces as P2002 and becomes a 409 in
// AllExceptionsFilter; an unknown apiary/harvest as a foreign-key error.
const lot = await this.prisma.rawMaterialLot.create({
data: {
kind: dto.kind,
lotNumber: dto.lotNumber,
lotNumber,
date: new Date(dto.date),
quantity: dto.quantity,
apiaryId: dto.apiaryId,
@@ -131,6 +144,34 @@ export class RawMaterialsService {
return after;
}
async withdraw(id: string, dto: WithdrawStockDto, actingUserId: string) {
const lot = await this.findById(id);
const stock = await this.stock.withdraw(
{ kind: 'raw', id },
dto,
actingUserId,
);
await this.checkLowStock(lot.kind);
return stock;
}
async correct(id: string, dto: CorrectStockDto, actingUserId: string) {
const lot = await this.findById(id);
const stock = await this.stock.correct(
{ kind: 'raw', id },
dto,
actingUserId,
);
await this.checkLowStock(lot.kind);
return stock;
}
/** Runs the RAW_MATERIAL_LOW rule against what is left of the kind. */
private async checkLowStock(kind: RawMaterialKind): Promise<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({