rework internal order system

This commit is contained in:
Matthias Hochmeister
2026-03-24 08:59:46 +01:00
parent 3c0a8a6832
commit 6ff5cc89ad
8 changed files with 240 additions and 154 deletions

View File

@@ -21,15 +21,15 @@ class AusruestungsanfrageController {
async createKategorie(req: Request, res: Response): Promise<void> {
try {
const { name } = req.body;
const { name, parent_id } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim());
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim(), parent_id ?? null);
res.status(201).json({ success: true, data: kategorie });
} catch (error: any) {
if (error?.constraint === 'ausruestung_kategorien_katalog_name_key') {
if (error?.constraint?.includes('unique') || error?.code === '23505') {
res.status(409).json({ success: false, message: 'Kategorie existiert bereits' });
return;
}
@@ -41,12 +41,15 @@ class AusruestungsanfrageController {
async updateKategorie(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { name } = req.body;
if (!name || name.trim().length === 0) {
const { name, parent_id } = req.body;
if (name !== undefined && (!name || name.trim().length === 0)) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.updateKategorie(id, name.trim());
const kategorie = await ausruestungsanfrageService.updateKategorie(id, {
name: name?.trim(),
parent_id: parent_id !== undefined ? parent_id : undefined,
});
if (!kategorie) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
@@ -451,6 +454,20 @@ class AusruestungsanfrageController {
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
}
}
// -------------------------------------------------------------------------
// Widget overview (lightweight, for dashboard widget)
// -------------------------------------------------------------------------
async getWidgetOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await ausruestungsanfrageService.getWidgetOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('AusruestungsanfrageController.getWidgetOverview error', { error });
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
}
}
}
export default new AusruestungsanfrageController();

View File

@@ -1,17 +1,23 @@
-- Migration 048: Catalog categories table + item characteristics
-- - Admin-managed categories (replacing free-text kategorie)
-- - Admin-managed categories with subcategories (replacing free-text kategorie)
-- - Per-item characteristics (options or free-text)
-- - Characteristic values per request position
-- - Remove view_all permission (approve covers it)
-- - Add manage_categories permission
-- 1. Categories table
-- 1. Categories table (with parent_id for subcategories)
CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
erstellt_am TIMESTAMPTZ DEFAULT NOW()
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE CASCADE,
erstellt_am TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(name, parent_id)
);
-- Add unique constraint for top-level categories (parent_id IS NULL)
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kategorien_top_level_unique
ON ausruestung_kategorien_katalog (name) WHERE parent_id IS NULL;
-- Migrate existing categories from free-text
INSERT INTO ausruestung_kategorien_katalog (name)
SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL AND kategorie != ''
@@ -24,7 +30,7 @@ ALTER TABLE ausruestung_artikel ADD COLUMN IF NOT EXISTS kategorie_id INT REFERE
UPDATE ausruestung_artikel a
SET kategorie_id = k.id
FROM ausruestung_kategorien_katalog k
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL;
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL AND k.parent_id IS NULL;
-- 2. Characteristics definitions per catalog item
CREATE TABLE IF NOT EXISTS ausruestung_artikel_eigenschaften (

View File

@@ -37,6 +37,7 @@ router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:v
// ---------------------------------------------------------------------------
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
router.get('/widget-overview', authenticate, requirePermission('ausruestungsanfrage:widget'), ausruestungsanfrageController.getWidgetOverview.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Requests

View File

@@ -2,28 +2,43 @@ import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Categories (ausruestung_kategorien_katalog)
// Categories (ausruestung_kategorien_katalog) — hierarchical with parent_id
// ---------------------------------------------------------------------------
async function getKategorien() {
const result = await pool.query(
'SELECT * FROM ausruestung_kategorien_katalog ORDER BY name',
'SELECT * FROM ausruestung_kategorien_katalog ORDER BY parent_id NULLS FIRST, name',
);
return result.rows;
}
async function createKategorie(name: string) {
async function createKategorie(name: string, parentId?: number | null) {
const result = await pool.query(
'INSERT INTO ausruestung_kategorien_katalog (name) VALUES ($1) RETURNING *',
[name],
'INSERT INTO ausruestung_kategorien_katalog (name, parent_id) VALUES ($1, $2) RETURNING *',
[name, parentId ?? null],
);
return result.rows[0];
}
async function updateKategorie(id: number, name: string) {
async function updateKategorie(id: number, data: { name?: string; parent_id?: number | null }) {
const fields: string[] = [];
const params: unknown[] = [];
if (data.name !== undefined) {
params.push(data.name);
fields.push(`name = $${params.length}`);
}
if (data.parent_id !== undefined) {
params.push(data.parent_id);
fields.push(`parent_id = $${params.length}`);
}
if (fields.length === 0) return null;
params.push(id);
const result = await pool.query(
'UPDATE ausruestung_kategorien_katalog SET name = $1 WHERE id = $2 RETURNING *',
[name, id],
`UPDATE ausruestung_kategorien_katalog SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
params,
);
return result.rows[0] || null;
}
@@ -279,25 +294,30 @@ async function getRequestById(id: number) {
[id],
);
// Load eigenschaft values per position
// Load eigenschaft values per position (gracefully handle missing table)
const positionIds = positionen.rows.map((p: { id: number }) => p.id);
let eigenschaftenMap: Record<number, { eigenschaft_id: number; eigenschaft_name: string; wert: string }[]> = {};
if (positionIds.length > 0) {
const eigenschaftenResult = await pool.query(
`SELECT pe.position_id, pe.eigenschaft_id, ae.name AS eigenschaft_name, pe.wert
FROM ausruestung_position_eigenschaften pe
JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id
WHERE pe.position_id = ANY($1)
ORDER BY ae.sort_order, ae.id`,
[positionIds],
);
for (const row of eigenschaftenResult.rows) {
if (!eigenschaftenMap[row.position_id]) eigenschaftenMap[row.position_id] = [];
eigenschaftenMap[row.position_id].push({
eigenschaft_id: row.eigenschaft_id,
eigenschaft_name: row.eigenschaft_name,
wert: row.wert,
});
try {
const eigenschaftenResult = await pool.query(
`SELECT pe.position_id, pe.eigenschaft_id, ae.name AS eigenschaft_name, pe.wert
FROM ausruestung_position_eigenschaften pe
JOIN ausruestung_artikel_eigenschaften ae ON ae.id = pe.eigenschaft_id
WHERE pe.position_id = ANY($1)
ORDER BY ae.sort_order, ae.id`,
[positionIds],
);
for (const row of eigenschaftenResult.rows) {
if (!eigenschaftenMap[row.position_id]) eigenschaftenMap[row.position_id] = [];
eigenschaftenMap[row.position_id].push({
eigenschaft_id: row.eigenschaft_id,
eigenschaft_name: row.eigenschaft_name,
wert: row.wert,
});
}
} catch (err) {
// Table may not exist yet if migration hasn't run
logger.debug('Position eigenschaften query failed (migration may not have run yet)', { error: err });
}
}
@@ -373,12 +393,16 @@ async function createRequest(
// Save eigenschaft values
if (item.eigenschaften && item.eigenschaften.length > 0) {
for (const e of item.eigenschaften) {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
try {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
} catch (err) {
logger.debug('Position eigenschaft insert failed (migration may not have run yet)', { error: err });
}
}
}
}
@@ -453,12 +477,16 @@ async function updateRequest(
// Save eigenschaft values
if (item.eigenschaften && item.eigenschaften.length > 0) {
for (const e of item.eigenschaften) {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
try {
await client.query(
`INSERT INTO ausruestung_position_eigenschaften (position_id, eigenschaft_id, wert)
VALUES ($1, $2, $3)
ON CONFLICT (position_id, eigenschaft_id) DO UPDATE SET wert = $3`,
[posResult.rows[0].id, e.eigenschaft_id, e.wert],
);
} catch (err) {
logger.debug('Position eigenschaft insert failed', { error: err });
}
}
}
}
@@ -566,6 +594,22 @@ async function getOverview() {
};
}
// ---------------------------------------------------------------------------
// Widget overview (no permission restriction — counts only)
// ---------------------------------------------------------------------------
async function getWidgetOverview() {
const result = await pool.query(
`SELECT
COUNT(*)::int AS total_count,
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
COUNT(*) FILTER (WHERE status = 'offen' AND bearbeitet_von IS NULL)::int AS unhandled_count
FROM ausruestung_anfragen`,
);
return result.rows[0];
}
export default {
getKategorien,
createKategorie,
@@ -591,4 +635,5 @@ export default {
unlinkFromOrder,
getLinkedOrders,
getOverview,
getWidgetOverview,
};