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({
|
||||
|
||||
Reference in new issue
Block a user