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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-07 19:33:10 +02:00
1 parent 5408711b2d
commit cfd3a187af
9 files changed
+238 -10

No files matched your search

@@ -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;
+17
View File
@@ -1365,6 +1365,23 @@ model AutomationRule {
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),
@@ -461,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', () => {
@@ -481,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({
@@ -32,6 +32,25 @@ const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
PROPOLIS: 'Propolis', 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;
@@ -77,8 +96,9 @@ 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: { RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: 'RS-{YYYY}-{SEQ:4}' }, config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '', actionTemplate: '',
}, },
PRODUCTION_BATCH_NUMBER_FORMAT: { PRODUCTION_BATCH_NUMBER_FORMAT: {
@@ -169,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' },
},
},
}); });
} }
@@ -385,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: NumberFormatTrigger, 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({
@@ -400,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(
@@ -415,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)
@@ -6,7 +6,10 @@ import {
} 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 } from '../automation/automation.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 { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto';
@@ -80,7 +83,10 @@ export class RawMaterialsService {
async create(dto: CreateRawMaterialLotDto, actingUserId: string) { async create(dto: CreateRawMaterialLotDto, actingUserId: string) {
const lotNumber = const lotNumber =
dto.lotNumber ?? dto.lotNumber ??
(await this.automation.generateNumber('RAW_MATERIAL_LOT_NUMBER_FORMAT')); (await this.automation.generateNumber('RAW_MATERIAL_LOT_NUMBER_FORMAT', {
scope: dto.kind,
vars: { ART: RAW_MATERIAL_KIND_CODES[dto.kind] },
}));
if (!lotNumber) { if (!lotNumber) {
throw new BadRequestException( throw new BadRequestException(
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Rohstoffe)" aktivieren)', 'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Rohstoffe)" aktivieren)',
@@ -74,6 +74,12 @@ const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [
{ kind: "PROPOLIS", label: "Mindestmenge Propolis (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>>; type MinGrams =Partial<Record<"BEESWAX" | "PROPOLIS", string>>;
function ruleMinGrams(rule: AutomationRule): MinGrams { function ruleMinGrams(rule: AutomationRule): MinGrams {
@@ -126,6 +132,11 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule)); const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(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 }) =>
@@ -194,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>
@@ -206,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">
@@ -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)
@@ -20,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;
} }