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)',
|
||||
|
||||
@@ -74,7 +74,13 @@ const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [
|
||||
{ kind: "PROPOLIS", label: "Mindestmenge Propolis (g)" },
|
||||
];
|
||||
|
||||
type MinGrams = Partial<Record<"BEESWAX" | "PROPOLIS", string>>;
|
||||
// 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>>;
|
||||
|
||||
function ruleMinGrams(rule: AutomationRule): MinGrams {
|
||||
const config = rule.config as { minGrams?: Record<string, unknown> } | null;
|
||||
@@ -126,6 +132,11 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
|
||||
const [minGrams, setMinGrams] = useState<MinGrams>(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<string, unknown>; actionTemplate?: string }) =>
|
||||
@@ -194,18 +205,43 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aktueller Zähler: {rule.sequenceValue}
|
||||
</p>
|
||||
{rule.active ? (
|
||||
<p className="text-sm font-medium">
|
||||
Beispiel für die nächste Nummer:{" "}
|
||||
<span className="font-mono">
|
||||
{formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{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">
|
||||
Aktueller Zähler: {rule.sequenceValue}
|
||||
</p>
|
||||
{rule.active ? (
|
||||
<p className="text-sm font-medium">
|
||||
Beispiel für die nächste Nummer:{" "}
|
||||
<span className="font-mono">
|
||||
{formatGeneratedNumber(format, rule.sequenceValue + 1, new Date())}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -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, 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)
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface AutomationRule {
|
||||
config: Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user