From 209d5a676e9535340dd5111e739b53b0d28380f1 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Tue, 24 Mar 2026 09:35:37 +0100 Subject: [PATCH] rework internal order system --- .../migrations/048_katalog_eigenschaften.sql | 16 +- .../services/ausruestungsanfrage.service.ts | 260 ++++++++++-------- frontend/src/pages/Ausruestungsanfrage.tsx | 30 +- 3 files changed, 173 insertions(+), 133 deletions(-) diff --git a/backend/src/database/migrations/048_katalog_eigenschaften.sql b/backend/src/database/migrations/048_katalog_eigenschaften.sql index e8a0326..d619346 100644 --- a/backend/src/database/migrations/048_katalog_eigenschaften.sql +++ b/backend/src/database/migrations/048_katalog_eigenschaften.sql @@ -1,23 +1,21 @@ -- Migration 048: Catalog categories table + item characteristics --- - 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 (with parent_id for subcategories) CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog ( 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) + erstellt_am TIMESTAMPTZ DEFAULT NOW() ); --- Add unique constraint for top-level categories (parent_id IS NULL) -CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kategorien_top_level_unique +-- Unique: top-level categories by name (where parent_id IS NULL) +CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_top_unique ON ausruestung_kategorien_katalog (name) WHERE parent_id IS NULL; +-- Unique: subcategories by (parent_id, name) +CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_child_unique + ON ausruestung_kategorien_katalog (parent_id, name) WHERE parent_id IS NOT 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 != '' diff --git a/backend/src/services/ausruestungsanfrage.service.ts b/backend/src/services/ausruestungsanfrage.service.ts index 1ecfaeb..9535c50 100644 --- a/backend/src/services/ausruestungsanfrage.service.ts +++ b/backend/src/services/ausruestungsanfrage.service.ts @@ -1,34 +1,22 @@ import pool from '../config/database'; import logger from '../utils/logger'; -// Helper: check if a table exists (cached per process lifetime) -const existingTables = new Set(); -async function tableExists(tableName: string): Promise { - if (existingTables.has(tableName)) return true; - try { - const r = await pool.query( - "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1) AS ok", - [tableName], - ); - if (r.rows[0]?.ok) { existingTables.add(tableName); return true; } - } catch { /* ignore */ } - return false; -} - // --------------------------------------------------------------------------- // Categories (ausruestung_kategorien_katalog) — hierarchical with parent_id // --------------------------------------------------------------------------- async function getKategorien() { - if (!(await tableExists('ausruestung_kategorien_katalog'))) return []; - const result = await pool.query( - 'SELECT * FROM ausruestung_kategorien_katalog ORDER BY parent_id NULLS FIRST, name', - ); - return result.rows; + try { + const result = await pool.query( + 'SELECT * FROM ausruestung_kategorien_katalog ORDER BY parent_id NULLS FIRST, name', + ); + return result.rows; + } catch { + return []; + } } async function createKategorie(name: string, parentId?: number | null) { - if (!(await tableExists('ausruestung_kategorien_katalog'))) throw new Error('Migration 048 has not been applied yet'); const result = await pool.query( 'INSERT INTO ausruestung_kategorien_katalog (name, parent_id) VALUES ($1, $2) RETURNING *', [name, parentId ?? null], @@ -73,54 +61,72 @@ async function getItems(filters?: { kategorie?: string; kategorie_id?: number; a if (filters?.kategorie) { params.push(filters.kategorie); - conditions.push(`a.kategorie = $${params.length}`); + conditions.push(`kategorie = $${params.length}`); } if (filters?.kategorie_id) { params.push(filters.kategorie_id); - conditions.push(`a.kategorie_id = $${params.length}`); + conditions.push(`kategorie_id = $${params.length}`); } if (filters?.aktiv !== undefined) { params.push(filters.aktiv); - conditions.push(`a.aktiv = $${params.length}`); + conditions.push(`aktiv = $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - const hasKategorien = await tableExists('ausruestung_kategorien_katalog'); - const hasEigenschaften = await tableExists('ausruestung_artikel_eigenschaften'); const result = await pool.query( - `SELECT a.*${hasKategorien ? ', k.name AS kategorie_name' : ''}${hasEigenschaften ? ',\n (SELECT COUNT(*)::int FROM ausruestung_artikel_eigenschaften e WHERE e.artikel_id = a.id) AS eigenschaften_count' : ''} - FROM ausruestung_artikel a - ${hasKategorien ? 'LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id' : ''} - ${where} - ORDER BY ${hasKategorien ? 'COALESCE(k.name, a.kategorie)' : 'a.kategorie'}, a.bezeichnung`, + `SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`, params, ); - return result.rows; + + // Enrich with kategorie_name and eigenschaften_count if tables exist + const rows = result.rows; + try { + const katRows = await pool.query('SELECT id, name FROM ausruestung_kategorien_katalog'); + const katMap = new Map(katRows.rows.map((k: { id: number; name: string }) => [k.id, k.name])); + for (const row of rows) { + row.kategorie_name = row.kategorie_id ? katMap.get(row.kategorie_id) || null : null; + } + } catch { /* table doesn't exist yet */ } + + try { + const eigCounts = await pool.query( + 'SELECT artikel_id, COUNT(*)::int AS cnt FROM ausruestung_artikel_eigenschaften GROUP BY artikel_id', + ); + const eigMap = new Map(eigCounts.rows.map((e: { artikel_id: number; cnt: number }) => [e.artikel_id, e.cnt])); + for (const row of rows) { + row.eigenschaften_count = eigMap.get(row.id) || 0; + } + } catch { /* table doesn't exist yet */ } + + return rows; } async function getItemById(id: number) { - const hasKategorien = await tableExists('ausruestung_kategorien_katalog'); - const result = await pool.query( - `SELECT a.*${hasKategorien ? ', k.name AS kategorie_name' : ''} - FROM ausruestung_artikel a - ${hasKategorien ? 'LEFT JOIN ausruestung_kategorien_katalog k ON k.id = a.kategorie_id' : ''} - WHERE a.id = $1`, - [id], - ); + const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); if (!result.rows[0]) return null; - let eigenschaften: { rows: unknown[] } = { rows: [] }; - if (await tableExists('ausruestung_artikel_eigenschaften')) { - eigenschaften = await pool.query( + const row = result.rows[0]; + + // Enrich with kategorie_name + if (row.kategorie_id) { + try { + const kat = await pool.query('SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1', [row.kategorie_id]); + row.kategorie_name = kat.rows[0]?.name || null; + } catch { /* table doesn't exist */ } + } + + // Load eigenschaften + try { + const eigenschaften = await pool.query( 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', [id], ); + row.eigenschaften = eigenschaften.rows; + } catch { + row.eigenschaften = []; } - return { - ...result.rows[0], - eigenschaften: eigenschaften.rows, - }; + return row; } async function createItem( @@ -134,11 +140,19 @@ async function createItem( }, userId: string, ) { + // Build column list dynamically based on whether kategorie_id column exists + const cols = ['bezeichnung', 'beschreibung', 'kategorie', 'geschaetzter_preis', 'aktiv', 'erstellt_von']; + const vals = [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId]; + + if (data.kategorie_id) { + cols.push('kategorie_id'); + vals.push(data.kategorie_id as unknown as string); + } + + const placeholders = vals.map((_, i) => `$${i + 1}`).join(', '); const result = await pool.query( - `INSERT INTO ausruestung_artikel (bezeichnung, beschreibung, kategorie, kategorie_id, geschaetzter_preis, aktiv, erstellt_von) - VALUES ($1, $2, $3, $4, COALESCE($5, true), $6, $7) - RETURNING *`, - [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.kategorie_id || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId], + `INSERT INTO ausruestung_artikel (${cols.join(', ')}) VALUES (${placeholders}) RETURNING *`, + vals, ); return result.rows[0]; } @@ -214,19 +228,21 @@ async function getCategories() { // --------------------------------------------------------------------------- async function getArtikelEigenschaften(artikelId: number) { - if (!(await tableExists('ausruestung_artikel_eigenschaften'))) return []; - const result = await pool.query( - 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', - [artikelId], - ); - return result.rows; + try { + const result = await pool.query( + 'SELECT * FROM ausruestung_artikel_eigenschaften WHERE artikel_id = $1 ORDER BY sort_order, id', + [artikelId], + ); + return result.rows; + } catch { + return []; + } } async function upsertArtikelEigenschaft( artikelId: number, data: { id?: number; name: string; typ: string; optionen?: string[]; pflicht?: boolean; sort_order?: number }, ) { - if (!(await tableExists('ausruestung_artikel_eigenschaften'))) throw new Error('Migration 048 has not been applied yet'); if (data.id) { const result = await pool.query( `UPDATE ausruestung_artikel_eigenschaften @@ -317,7 +333,7 @@ async function getRequestById(id: number) { [id], ); - // Load eigenschaft values per position (gracefully handle missing table) + // Load eigenschaft values per position const positionIds = positionen.rows.map((p: { id: number }) => p.id); let eigenschaftenMap: Record = {}; if (positionIds.length > 0) { @@ -338,10 +354,7 @@ async function getRequestById(id: number) { 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 }); - } + } catch { /* table may not exist */ } } const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({ @@ -349,18 +362,23 @@ async function getRequestById(id: number) { eigenschaften: eigenschaftenMap[p.id] || [], })); - const bestellungen = await pool.query( - `SELECT b.* - FROM ausruestung_anfrage_bestellung ab - JOIN bestellungen b ON b.id = ab.bestellung_id - WHERE ab.anfrage_id = $1`, - [id], - ); + // Load linked bestellungen + let linkedBestellungen: unknown[] = []; + try { + const bestellungen = await pool.query( + `SELECT b.* + FROM ausruestung_anfrage_bestellung ab + JOIN bestellungen b ON b.id = ab.bestellung_id + WHERE ab.anfrage_id = $1`, + [id], + ); + linkedBestellungen = bestellungen.rows; + } catch { /* table may not exist */ } return { anfrage: reqResult.rows[0], positionen: positionenWithEigenschaften, - linked_bestellungen: bestellungen.rows, + linked_bestellungen: linkedBestellungen, }; } @@ -374,7 +392,6 @@ async function createRequest( try { await client.query('BEGIN'); - // Get next bestell_nummer for the current year const currentYear = new Date().getFullYear(); const maxResult = await client.query( `SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr @@ -394,8 +411,6 @@ async function createRequest( for (const item of items) { let itemBezeichnung = item.bezeichnung; - - // If artikel_id is provided, copy bezeichnung from catalog if (item.artikel_id) { const artikelResult = await client.query( 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', @@ -406,31 +421,43 @@ async function createRequest( } } - const posResult = await client.query( + await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) - VALUES ($1, $2, $3, $4, $5) - RETURNING id`, + VALUES ($1, $2, $3, $4, $5)`, [anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null], ); - // Save eigenschaft values - if (item.eigenschaften && item.eigenschaften.length > 0) { - for (const e of item.eigenschaften) { - 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 }); - } - } - } + // NOTE: eigenschaft values are NOT saved in the transaction to avoid + // aborting the transaction if the table doesn't exist. } await client.query('COMMIT'); + + // Save eigenschaft values OUTSIDE the transaction so a missing table + // won't rollback the entire request creation. + for (const item of items) { + if (!item.eigenschaften || item.eigenschaften.length === 0) continue; + // Find the position ID we just inserted + const posRes = await pool.query( + `SELECT id FROM ausruestung_anfrage_positionen + WHERE anfrage_id = $1 AND COALESCE(artikel_id, 0) = $2 + ORDER BY id DESC LIMIT 1`, + [anfrage.id, item.artikel_id || 0], + ); + if (posRes.rows.length === 0) continue; + const posId = posRes.rows[0].id; + for (const e of item.eigenschaften) { + try { + await pool.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`, + [posId, e.eigenschaft_id, e.wert], + ); + } catch { /* table may not exist */ } + } + } + return getRequestById(anfrage.id); } catch (error) { await client.query('ROLLBACK'); @@ -453,7 +480,6 @@ async function updateRequest( try { await client.query('BEGIN'); - // Update anfrage fields const fields: string[] = []; const params: unknown[] = []; @@ -476,7 +502,6 @@ async function updateRequest( ); } - // Replace items if provided if (data.items) { await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]); for (const item of data.items) { @@ -490,32 +515,41 @@ async function updateRequest( itemBezeichnung = artikelResult.rows[0].bezeichnung; } } - const posResult = await client.query( + await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) - VALUES ($1, $2, $3, $4, $5) - RETURNING id`, + VALUES ($1, $2, $3, $4, $5)`, [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null], ); - - // Save eigenschaft values - if (item.eigenschaften && item.eigenschaften.length > 0) { - for (const e of item.eigenschaften) { - 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 }); - } - } - } } } await client.query('COMMIT'); + + // Save eigenschaft values outside transaction + if (data.items) { + for (const item of data.items) { + if (!item.eigenschaften || item.eigenschaften.length === 0) continue; + const posRes = await pool.query( + `SELECT id FROM ausruestung_anfrage_positionen + WHERE anfrage_id = $1 AND COALESCE(artikel_id, 0) = $2 + ORDER BY id DESC LIMIT 1`, + [id, item.artikel_id || 0], + ); + if (posRes.rows.length === 0) continue; + const posId = posRes.rows[0].id; + for (const e of item.eigenschaften) { + try { + await pool.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`, + [posId, e.eigenschaft_id, e.wert], + ); + } catch { /* table may not exist */ } + } + } + } + return getRequestById(id); } catch (error) { await client.query('ROLLBACK'); @@ -618,7 +652,7 @@ async function getOverview() { } // --------------------------------------------------------------------------- -// Widget overview (no permission restriction — counts only) +// Widget overview (lightweight counts only) // --------------------------------------------------------------------------- async function getWidgetOverview() { diff --git a/frontend/src/pages/Ausruestungsanfrage.tsx b/frontend/src/pages/Ausruestungsanfrage.tsx index e61842a..79f20e6 100644 --- a/frontend/src/pages/Ausruestungsanfrage.tsx +++ b/frontend/src/pages/Ausruestungsanfrage.tsx @@ -336,10 +336,11 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can const [linkDialog, setLinkDialog] = useState(false); const [selectedBestellung, setSelectedBestellung] = useState(null); - const { data: detail, isLoading } = useQuery({ + const { data: detail, isLoading, isError } = useQuery({ queryKey: ['ausruestungsanfrage', 'request', requestId], queryFn: () => ausruestungsanfrageApi.getRequest(requestId!), enabled: requestId != null, + retry: 1, }); const { data: catalogItems = [] } = useQuery({ @@ -451,6 +452,8 @@ function DetailModal({ requestId, onClose, showAdminActions, showEditButton, can {isLoading ? ( Lade Details... + ) : isError ? ( + Fehler beim Laden der Anfrage. ) : !detail ? ( Anfrage nicht gefunden. ) : editing ? ( @@ -945,8 +948,12 @@ function MeineAnfragenTab() { if (itemEigenschaftenRef.current[artikelId]) return; try { const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId); - setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs })); - } catch { /* ignore */ } + if (eigs && eigs.length > 0) { + setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs })); + } + } catch (err) { + console.warn('Failed to load eigenschaften for artikel', artikelId, err); + } }, []); const handleCreateSubmit = () => { @@ -1212,21 +1219,19 @@ function AlleAnfragenTab() { const canEditAny = hasPermission('ausruestungsanfrage:edit'); - const { data: requests = [], isLoading } = useQuery({ - queryKey: ['ausruestungsanfrage', 'requests', statusFilter], + const { data: requests = [], isLoading: requestsLoading, isError: requestsError } = useQuery({ + queryKey: ['ausruestungsanfrage', 'allRequests', statusFilter], queryFn: () => ausruestungsanfrageApi.getRequests(statusFilter ? { status: statusFilter } : undefined), }); const { data: overview } = useQuery({ - queryKey: ['ausruestungsanfrage', 'overview'], + queryKey: ['ausruestungsanfrage', 'overview-cards'], queryFn: () => ausruestungsanfrageApi.getOverview(), }); - if (isLoading) return Lade Anfragen...; - return ( - {/* Summary cards */} + {/* Summary cards — always visible */} @@ -1268,7 +1273,11 @@ function AlleAnfragenTab() { ))} - {requests.length === 0 ? ( + {requestsLoading ? ( + Lade Anfragen... + ) : requestsError ? ( + Fehler beim Laden der Anfragen. + ) : requests.length === 0 ? ( Keine Anfragen vorhanden. ) : ( @@ -1299,7 +1308,6 @@ function AlleAnfragenTab() { )} - {/* Detail Modal with admin actions */} setDetailId(null)}