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
+239
-11
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)
|
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,7 +74,13 @@ const MIN_GRAMS_FIELDS: { kind: "BEESWAX" | "PROPOLIS"; label: string }[] = [
|
|||||||
{ kind: "PROPOLIS", label: "Mindestmenge Propolis (g)" },
|
{ 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 {
|
function ruleMinGrams(rule: AutomationRule): MinGrams {
|
||||||
const config = rule.config as { minGrams?: Record<string, unknown> } | null;
|
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 [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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user