This commit is contained in:
Matthias Hochmeister
2026-03-26 14:22:35 +01:00
parent 3c95b7506b
commit c29b21f714
9 changed files with 400 additions and 103 deletions

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import bestellungService from '../services/bestellung.service';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
import fs from 'fs';
@@ -227,7 +228,34 @@ class BestellungController {
return;
}
try {
const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force);
// For force override, require manage_orders
if (force) {
const canManage = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:manage_orders');
if (!canManage) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für manuelle Statusänderung' });
return;
}
}
// For approval/rejection transitions, require bestellungen:approve
if (status === 'bereit_zur_bestellung' || status === 'entwurf') {
// Check if this is an approval/rejection (from wartet_auf_genehmigung)
const currentOrder = await bestellungService.getOrderById(id);
if (!currentOrder) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
const currentStatus = currentOrder.bestellung.status;
if (currentStatus === 'wartet_auf_genehmigung') {
const canApprove = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:approve');
if (!canApprove) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zur Genehmigung/Ablehnung von Bestellungen' });
return;
}
}
}
const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force, req.user!.id);
if (!order) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
@@ -327,7 +355,11 @@ class BestellungController {
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
} catch (error: any) {
if (error.statusCode === 400) {
res.status(400).json({ success: false, message: error.message });
return;
}
logger.error('BestellungController.updateReceivedQuantity error', { error });
res.status(500).json({ success: false, message: 'Liefermenge konnte nicht aktualisiert werden' });
}
@@ -501,6 +533,11 @@ class BestellungController {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
const orderStatus = order.bestellung.status;
if (orderStatus === 'entwurf' || orderStatus === 'wartet_auf_genehmigung') {
res.status(403).json({ success: false, message: 'Export nur nach Genehmigung verfügbar' });
return;
}
res.status(200).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.exportOrder error', { error });

View File

@@ -0,0 +1,63 @@
-- Migration 064: Add spezifikationen to line items, approval columns, and new status workflow
-- 1. spezifikationen JSONB on bestellpositionen
-- 2. genehmigt_von / genehmigt_am on bestellungen
-- 3. Drop old status CHECK constraint
-- 4. Data migration: old statuses → new statuses
-- 5. Add new status CHECK constraint with approval workflow statuses
-- 6. bestellungen:approve permission + seed for dashboard_kommando
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Spezifikationen on line items
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellpositionen
ADD COLUMN IF NOT EXISTS spezifikationen JSONB DEFAULT '[]';
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Approval columns on bestellungen
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellungen
ADD COLUMN IF NOT EXISTS genehmigt_von UUID REFERENCES users(id),
ADD COLUMN IF NOT EXISTS genehmigt_am TIMESTAMPTZ;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Drop old status CHECK constraint
-- ═══════════════════════════════════════════════════════════════════════════
DO $$ BEGIN
ALTER TABLE bestellungen DROP CONSTRAINT IF EXISTS bestellungen_status_check;
EXCEPTION WHEN undefined_object THEN
NULL;
END $$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Data migration: map old statuses to new ones (before new constraint)
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE bestellungen SET status = 'bereit_zur_bestellung' WHERE status = 'erstellt';
UPDATE bestellungen SET status = 'lieferung_pruefen' WHERE status = 'vollstaendig';
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Add new status CHECK constraint
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellungen
ADD CONSTRAINT bestellungen_status_check
CHECK (status IN ('entwurf','wartet_auf_genehmigung','bereit_zur_bestellung','bestellt','teillieferung','lieferung_pruefen','abgeschlossen'));
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. Add bestellungen:approve permission
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('bestellungen:approve', 'bestellungen', 'Genehmigen', 'Bestellungen genehmigen', 25)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. Seed grant for dashboard_kommando
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'bestellungen:approve')
ON CONFLICT DO NOTHING;

View File

@@ -102,7 +102,7 @@ router.delete(
router.patch(
'/:id/status',
authenticate,
requirePermission('bestellungen:manage_orders'),
requirePermission('bestellungen:create'),
bestellungController.updateStatus.bind(bestellungController)
);

View File

@@ -5,6 +5,8 @@
import pool from '../config/database';
import logger from '../utils/logger';
import fs from 'fs';
import notificationService from './notification.service';
import { permissionService } from './permission.service';
// ---------------------------------------------------------------------------
// Vendors (Lieferanten)
@@ -318,20 +320,22 @@ async function deleteOrder(id: number, _userId: string) {
}
const VALID_STATUS_TRANSITIONS: Record<string, string[]> = {
entwurf: ['erstellt', 'bestellt'],
erstellt: ['bestellt'],
bestellt: ['teillieferung', 'vollstaendig'],
teillieferung: ['vollstaendig'],
vollstaendig: ['abgeschlossen'],
abgeschlossen: [],
entwurf: ['wartet_auf_genehmigung'],
wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'],
bereit_zur_bestellung: ['bestellt'],
bestellt: ['teillieferung', 'lieferung_pruefen'],
teillieferung: ['lieferung_pruefen'],
lieferung_pruefen: ['abgeschlossen'],
abgeschlossen: [],
};
async function updateOrderStatus(id: number, status: string, userId: string, force?: boolean) {
async function updateOrderStatus(id: number, status: string, userId: string, force?: boolean, approverUserId?: string) {
try {
const current = await pool.query(`SELECT status FROM bestellungen WHERE id = $1`, [id]);
const current = await pool.query(`SELECT * FROM bestellungen WHERE id = $1`, [id]);
if (current.rows.length === 0) return null;
const oldStatus = current.rows[0].status;
const order = current.rows[0];
const oldStatus = order.status;
if (!force) {
const allowed = VALID_STATUS_TRANSITIONS[oldStatus] || [];
if (!allowed.includes(status)) {
@@ -349,6 +353,12 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
if (status === 'abgeschlossen') {
updates.push(`abgeschlossen_am = COALESCE(abgeschlossen_am, NOW())`);
}
if (status === 'bereit_zur_bestellung' && approverUserId) {
updates.push(`genehmigt_von = $${paramIndex}`);
params.push(approverUserId);
paramIndex++;
updates.push(`genehmigt_am = NOW()`);
}
params.push(id);
const result = await pool.query(
@@ -357,6 +367,53 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
);
await logAction(id, 'Status geändert', `${oldStatus}${status}${force ? ' (manuell)' : ''}`, userId);
// Notifications based on transition
const orderLabel = order.laufende_nummer ? `#${order.laufende_nummer}` : `#${order.id}`;
const bestellerId = order.besteller_id || order.erstellt_von;
if (status === 'wartet_auf_genehmigung') {
try {
const approvers = await permissionService.getUsersWithPermission('bestellungen:approve');
for (const user of approvers) {
await notificationService.createNotification({
user_id: user.id,
typ: 'bestellung',
titel: 'Bestellung wartet auf Genehmigung',
nachricht: `Bestellung ${orderLabel} wurde zur Genehmigung eingereicht`,
schwere: 'info',
quell_typ: 'bestellung_genehmigung',
quell_id: order.id.toString(),
link: `/bestellungen/${id}`,
});
}
} catch (err) {
logger.error('Failed to notify approvers', { err, orderId: id });
}
} else if (status === 'bereit_zur_bestellung') {
await notificationService.createNotification({
user_id: bestellerId,
typ: 'bestellung',
titel: 'Bestellung genehmigt',
nachricht: `Bestellung ${orderLabel} wurde genehmigt und ist bereit zur Bestellung`,
schwere: 'info',
quell_typ: 'bestellung_genehmigung',
quell_id: order.id.toString(),
link: `/bestellungen/${id}`,
});
} else if (status === 'entwurf' && oldStatus === 'wartet_auf_genehmigung') {
await notificationService.createNotification({
user_id: bestellerId,
typ: 'bestellung',
titel: 'Bestellung abgelehnt',
nachricht: `Bestellung ${orderLabel} wurde abgelehnt und zurück in den Entwurf gesetzt`,
schwere: 'warnung',
quell_typ: 'bestellung_genehmigung',
quell_id: order.id.toString(),
link: `/bestellungen/${id}`,
});
}
return result.rows[0];
} catch (error) {
logger.error('BestellungService.updateOrderStatus failed', { error, id });
@@ -368,13 +425,13 @@ async function updateOrderStatus(id: number, status: string, userId: string, for
// Line Items (Bestellpositionen)
// ---------------------------------------------------------------------------
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
async function addLineItem(bestellungId: number, data: { bezeichnung: string; artikelnummer?: string; menge: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) {
try {
const result = await pool.query(
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, artikelnummer, menge, einheit, einzelpreis, notizen, spezifikationen)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
RETURNING *`,
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null]
[bestellungId, data.bezeichnung, data.artikelnummer || null, data.menge, data.einheit || 'Stk', data.einzelpreis || 0, data.notizen || null, JSON.stringify(data.spezifikationen || [])]
);
await logAction(bestellungId, 'Position hinzugefügt', `"${data.bezeichnung}" x${data.menge}`, userId);
return result.rows[0];
@@ -384,7 +441,7 @@ async function addLineItem(bestellungId: number, data: { bezeichnung: string; ar
}
}
async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string }, userId: string) {
async function updateLineItem(id: number, data: { bezeichnung?: string; artikelnummer?: string; menge?: number; einheit?: string; einzelpreis?: number; notizen?: string; spezifikationen?: string[] }, userId: string) {
try {
const result = await pool.query(
`UPDATE bestellpositionen
@@ -393,10 +450,11 @@ async function updateLineItem(id: number, data: { bezeichnung?: string; artikeln
menge = COALESCE($3, menge),
einheit = COALESCE($4, einheit),
einzelpreis = COALESCE($5, einzelpreis),
notizen = COALESCE($6, notizen)
WHERE id = $7
notizen = COALESCE($6, notizen),
spezifikationen = COALESCE($7::jsonb, spezifikationen)
WHERE id = $8
RETURNING *`,
[data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, id]
[data.bezeichnung, data.artikelnummer, data.menge, data.einheit, data.einzelpreis, data.notizen, data.spezifikationen ? JSON.stringify(data.spezifikationen) : null, id]
);
if (result.rows.length === 0) return null;
@@ -425,6 +483,21 @@ async function deleteLineItem(id: number, userId: string) {
async function updateReceivedQuantity(id: number, menge: number, userId: string) {
try {
// First check if the item's order is in a valid status for receiving
const itemCheck = await pool.query(
`SELECT bp.bestellung_id, b.status
FROM bestellpositionen bp
JOIN bestellungen b ON b.id = bp.bestellung_id
WHERE bp.id = $1`,
[id]
);
if (itemCheck.rows.length > 0) {
const orderStatus = itemCheck.rows[0].status;
if (orderStatus !== 'bestellt' && orderStatus !== 'teillieferung') {
throw Object.assign(new Error('Mengenaktualisierung nur bei Status bestellt oder Teillieferung möglich'), { statusCode: 400 });
}
}
const result = await pool.query(
`UPDATE bestellpositionen SET erhalten_menge = $1 WHERE id = $2 RETURNING *`,
[menge, id]
@@ -447,10 +520,10 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string)
if (order.rows.length > 0 && (order.rows[0].status === 'bestellt' || order.rows[0].status === 'teillieferung')) {
if (allReceived) {
await pool.query(
`UPDATE bestellungen SET status = 'vollstaendig', aktualisiert_am = NOW() WHERE id = $1`,
`UPDATE bestellungen SET status = 'lieferung_pruefen', aktualisiert_am = NOW() WHERE id = $1`,
[item.bestellung_id]
);
await logAction(item.bestellung_id, 'Status geändert', 'Alle Positionen vollständig erhalten → vollstaendig', userId);
await logAction(item.bestellung_id, 'Status geändert', 'Alle Positionen vollständig erhalten → lieferung_pruefen', userId);
} else if (someReceived && order.rows[0].status === 'bestellt') {
await pool.query(
`UPDATE bestellungen SET status = 'teillieferung', aktualisiert_am = NOW() WHERE id = $1`,
@@ -463,7 +536,7 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string)
return item;
} catch (error) {
logger.error('BestellungService.updateReceivedQuantity failed', { error, id });
throw new Error('Liefermenge konnte nicht aktualisiert werden');
throw error;
}
}