From cfd3a187af806a95596b105f3706af5b65c7049d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 7 Oct 2026 19:33:10 +0200 Subject: [PATCH] feat(automation): separate lot number counters per raw material kind 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) --- .../migration.sql | 12 +++ ApiCore-Backend/prisma/schema.prisma | 17 ++++ .../src/automation/automation.service.spec.ts | 71 +++++++++++++++- .../src/automation/automation.service.ts | 82 +++++++++++++++++-- .../src/automation/number-format.ts | 9 +- .../raw-materials/raw-materials.service.ts | 10 ++- .../automation/automation-rules-tab.tsx | 60 +++++++++++--- .../src/lib/automation/number-format.ts | 13 ++- ApiCore-Frontend/src/lib/automation/types.ts | 2 + 9 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261007110000_automation_sequences/migration.sql diff --git a/ApiCore-Backend/prisma/migrations/20261007110000_automation_sequences/migration.sql b/ApiCore-Backend/prisma/migrations/20261007110000_automation_sequences/migration.sql new file mode 100644 index 0000000..70de54e --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261007110000_automation_sequences/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 3aee330..1d474e0 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -1365,6 +1365,23 @@ model AutomationRule { 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]) } // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 912e799..d23ece1 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -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), @@ -461,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', () => { @@ -481,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({ diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index c0a7682..713e75b 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -32,6 +32,25 @@ const RAW_MATERIAL_KIND_LABELS: Record = { PROPOLIS: 'Propolis', }; +/** What {ART} turns into in the raw material number format. */ +export const RAW_MATERIAL_KIND_CODES: Record = { + 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; actionTemplate: string; @@ -77,8 +96,9 @@ const DEFAULT_RULES: Record = { config: { format: 'WH-{YYYY}-{SEQ:4}' }, actionTemplate: '', }, + // {ART} (WA/PR) gives wax and propolis a counter each. RAW_MATERIAL_LOT_NUMBER_FORMAT: { - config: { format: 'RS-{YYYY}-{SEQ:4}' }, + config: { format: '{ART}-{YYYY}-{SEQ:4}' }, actionTemplate: '', }, PRODUCTION_BATCH_NUMBER_FORMAT: { @@ -169,10 +189,16 @@ export class AutomationService { } } - async findAll(): Promise { + async findAll(): Promise { await this.ensureDefaults(); return this.prisma.automationRule.findMany({ 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 // 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: NumberFormatTrigger, + options: { scope?: string; vars?: Record } = {}, ): Promise { await this.ensureDefaults(); const rule = await this.prisma.automationRule.findUnique({ @@ -400,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 { 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 { + 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( @@ -415,9 +484,10 @@ export class AutomationService { actingUserId: string, ): Promise { 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', diff --git a/ApiCore-Backend/src/automation/number-format.ts b/ApiCore-Backend/src/automation/number-format.ts index a041b10..9782b36 100644 --- a/ApiCore-Backend/src/automation/number-format.ts +++ b/ApiCore-Backend/src/automation/number-format.ts @@ -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 { 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) diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts index 8f3dc5d..f59e474 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts @@ -6,7 +6,10 @@ import { } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.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 { RawMaterialKind } from '../../generated/prisma'; import { WithdrawStockDto } from '../stock/dto/withdraw-stock.dto'; @@ -80,7 +83,10 @@ export class RawMaterialsService { async create(dto: CreateRawMaterialLotDto, actingUserId: string) { const 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) { throw new BadRequestException( 'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format (Rohstoffe)" aktivieren)', diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 450ef58..ad24fbd 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -74,7 +74,13 @@ const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [ { kind: "PROPOLIS", label: "Mindestmenge Propolis (g)" }, ]; -type MinGrams = Partial>; +// 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>; function ruleMinGrams(rule: AutomationRule): MinGrams { const config = rule.config as { minGrams?: Record } | null; @@ -126,6 +132,11 @@ function RuleCard({ rule }: { rule: AutomationRule }) { const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule)); const [minGrams, setMinGrams] = useState(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; actionTemplate?: string }) => @@ -194,18 +205,43 @@ function RuleCard({ rule }: { rule: AutomationRule }) {

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}

-

- Aktueller Zähler: {rule.sequenceValue} -

- {rule.active ? ( -

- Beispiel für die nächste Nummer:{" "} - - {formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())} - -

- ) : null} + {perKind ? ( +
    + {RAW_KIND_CODES.map(({ kind, code, label }) => ( +
  • + Zähler {label}: {kindSequence(kind)} + {rule.active ? ( + <> + {" "}– nächste Nummer{" "} + + {formatGeneratedNumber(format, kindSequence(kind) + 1, new Date(), { + ART: code, + })} + + + ) : null} +
  • + ))} +
+ ) : ( + <> +

+ Aktueller Zähler: {rule.sequenceValue} +

+ {rule.active ? ( +

+ Beispiel für die nächste Nummer:{" "} + + {formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())} + +

+ ) : null} + + )} ) : (
diff --git a/ApiCore-Frontend/src/lib/automation/number-format.ts b/ApiCore-Frontend/src/lib/automation/number-format.ts index 332c2e2..66fd137 100644 --- a/ApiCore-Frontend/src/lib/automation/number-format.ts +++ b/ApiCore-Frontend/src/lib/automation/number-format.ts @@ -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 { 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) diff --git a/ApiCore-Frontend/src/lib/automation/types.ts b/ApiCore-Frontend/src/lib/automation/types.ts index 37d12bd..b639486 100644 --- a/ApiCore-Frontend/src/lib/automation/types.ts +++ b/ApiCore-Frontend/src/lib/automation/types.ts @@ -20,6 +20,8 @@ export interface AutomationRule { config: Record | 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; }