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) <[email protected]>
This commit is contained in:
1 parent
5408711b2d
commit
cfd3a187af
9 files changed
+252
-24
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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -32,6 +32,25 @@ const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
|
||||
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 {
|
||||
config: Record<string, unknown>;
|
||||
actionTemplate: string;
|
||||
@@ -77,8 +96,9 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
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<AutomationRule[]> {
|
||||
async findAll(): Promise<AutomationRuleWithSequences[]> {
|
||||
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<string, string> } = {},
|
||||
): Promise<string | null> {
|
||||
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<number> {
|
||||
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<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(
|
||||
@@ -415,9 +484,10 @@ export class AutomationService {
|
||||
actingUserId: string,
|
||||
): Promise<AutomationRule> {
|
||||
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',
|
||||
|
||||
@@ -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, string> = {},
|
||||
): 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)
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in new issue
Block a user