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:
1 parent
fc887d8431
commit
5408711b2d
18 files changed
+311
-26
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user