feat: add website social link, fix herkunft origin display, GUI backup schedule, and format-based batch numbering
- SiteSettings gains websiteUrl (e.g. online shop link) alongside the
existing social links, editable in Verwaltung > Automatisierung.
- /herkunft/[lot] now shows the apiary's Standort instead of its internal
Name (so multiple apiaries at one location read as one origin), adds
small icons per social link, and surfaces previously-fetched-but-unused
data (Honigsorte, Abfülldatum, stat cards) to make the page a richer
customer-facing info page.
- Backup schedule (path/cron/retention) is now also configurable via the
GUI (Verwaltung > Backup), stored in a new BackupSettings singleton;
an env var always wins over the GUI value per field, and the scheduler
reloads its cron job live when GUI settings change.
- New Automatisierung rules let an admin define a format (e.g.
"WH-{YYYY}-{SEQ:4}") that auto-generates HoneyBatch.batchNumber and
BottlingBatch.lotNumber on creation, with an atomic per-rule sequence
counter and a manual reset. Manual entry still works when the rule is
inactive.
This commit is contained in:
1 parent
7b23862a41
commit
412d033d8d
42 files changed
+1045
-119
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@@ -27,4 +27,13 @@ export class AutomationController {
|
||||
) {
|
||||
return this.automationService.update(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('automation:update')
|
||||
@Post(':id/reset-sequence')
|
||||
resetSequence(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.automationService.resetSequence(id, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ describe('AutomationService', () => {
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6);
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -307,4 +307,86 @@ describe('AutomationService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNumber', () => {
|
||||
it('returns null when the rule is inactive', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'rule-4',
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
|
||||
active: false,
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
sequenceValue: 0,
|
||||
});
|
||||
|
||||
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.automationRule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when no format is configured', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'rule-4',
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
|
||||
active: true,
|
||||
config: {},
|
||||
sequenceValue: 0,
|
||||
});
|
||||
|
||||
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('atomically increments sequenceValue and formats the number', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'rule-4',
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
|
||||
active: true,
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
sequenceValue: 0,
|
||||
});
|
||||
prisma.automationRule.update.mockResolvedValue({ sequenceValue: 7 });
|
||||
|
||||
const result = await service.generateNumber('HONEY_BATCH_NUMBER_FORMAT');
|
||||
|
||||
expect(prisma.automationRule.update).toHaveBeenCalledWith({
|
||||
where: { triggerType: 'HONEY_BATCH_NUMBER_FORMAT' },
|
||||
data: { sequenceValue: { increment: 1 } },
|
||||
select: { sequenceValue: true },
|
||||
});
|
||||
expect(result).toBe(`WH-${new Date().getFullYear()}-0007`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetSequence', () => {
|
||||
it('sets sequenceValue back to 0 and records an audit log entry', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'rule-4',
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT',
|
||||
active: true,
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
sequenceValue: 12,
|
||||
});
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
id: 'rule-4',
|
||||
sequenceValue: 0,
|
||||
});
|
||||
|
||||
await service.resetSequence('rule-4', 'user-1');
|
||||
|
||||
expect(prisma.automationRule.update).toHaveBeenCalledWith({
|
||||
where: { id: 'rule-4' },
|
||||
data: { sequenceValue: 0 },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'AutomationRule',
|
||||
entityId: 'rule-4',
|
||||
action: 'UPDATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
VarroaMeasurement,
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
|
||||
interface RuleDefaults {
|
||||
config: Record<string, unknown>;
|
||||
@@ -48,6 +49,17 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
config: {},
|
||||
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
||||
},
|
||||
// Not task-generating rules like the ones above - `active` here toggles
|
||||
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
||||
// number from `config.format` instead of requiring manual entry.
|
||||
HONEY_BATCH_NUMBER_FORMAT: {
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
BOTTLING_LOT_NUMBER_FORMAT: {
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -215,6 +227,53 @@ export class AutomationService {
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
}
|
||||
|
||||
// Returns null (never throws) when the rule is inactive or has no format
|
||||
// configured, so the caller can fall back to requiring manual entry.
|
||||
async generateNumber(
|
||||
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
|
||||
): Promise<string | null> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return null;
|
||||
}
|
||||
const config = rule.config as { format?: string } | null;
|
||||
const format = config?.format;
|
||||
if (!format) {
|
||||
return null;
|
||||
}
|
||||
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
|
||||
// batch/bottling creation, no explicit transaction/lock needed.
|
||||
const updated = await this.prisma.automationRule.update({
|
||||
where: { triggerType },
|
||||
data: { sequenceValue: { increment: 1 } },
|
||||
select: { sequenceValue: true },
|
||||
});
|
||||
return formatGeneratedNumber(format, updated.sequenceValue, new Date());
|
||||
}
|
||||
|
||||
async resetSequence(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
): Promise<AutomationRule> {
|
||||
const before = await this.findById(id);
|
||||
const rule = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
data: { sequenceValue: 0 },
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'AutomationRule',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: rule,
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
private async triggerAction(
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
|
||||
describe('formatGeneratedNumber', () => {
|
||||
// Constructed via local-time components (not an ISO string) so the test
|
||||
// is deterministic regardless of the machine's timezone.
|
||||
const date = new Date(2026, 2, 7);
|
||||
|
||||
it('substitutes year, month, day and a zero-padded sequence', () => {
|
||||
const result = formatGeneratedNumber('WH-{YYYY}-{SEQ:4}', 7, date);
|
||||
|
||||
expect(result).toBe('WH-2026-0007');
|
||||
});
|
||||
|
||||
it('supports {YY}, {MM}, {DD} and an unpadded {SEQ}', () => {
|
||||
const result = formatGeneratedNumber('{YY}{MM}{DD}-{SEQ}', 3, date);
|
||||
|
||||
expect(result).toBe('260307-3');
|
||||
});
|
||||
|
||||
it('does not truncate a sequence wider than the requested padding', () => {
|
||||
const result = formatGeneratedNumber('L-{SEQ:2}', 12345, date);
|
||||
|
||||
expect(result).toBe('L-12345');
|
||||
});
|
||||
|
||||
it('leaves a format with no placeholders unchanged', () => {
|
||||
const result = formatGeneratedNumber('FIXED-NUMBER', 1, date);
|
||||
|
||||
expect(result).toBe('FIXED-NUMBER');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Placeholders: {YYYY} 4-digit year, {YY} 2-digit year, {MM} month (01-12),
|
||||
// {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}".
|
||||
export function formatGeneratedNumber(
|
||||
format: string,
|
||||
sequence: number,
|
||||
date: Date,
|
||||
): 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
|
||||
.replace(/\{YYYY\}/g, yyyy)
|
||||
.replace(/\{YY\}/g, yy)
|
||||
.replace(/\{MM\}/g, mm)
|
||||
.replace(/\{DD\}/g, dd)
|
||||
.replace(/\{SEQ:(\d+)\}/g, (_match, width: string) =>
|
||||
String(sequence).padStart(Number(width), '0'),
|
||||
)
|
||||
.replace(/\{SEQ\}/g, String(sequence));
|
||||
}
|
||||
Reference in new issue
Block a user