import pool from '../config/database'; import logger from '../utils/logger'; // --------------------------------------------------------------------------- // Categories (ausruestung_kategorien_katalog) — hierarchical with parent_id // --------------------------------------------------------------------------- async function getKategorien() { 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) { const result = await pool.query( 'INSERT INTO ausruestung_kategorien_katalog (name, parent_id) VALUES ($1, $2) RETURNING *', [name, parentId ?? null], ); return result.rows[0]; } 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 ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`, params, ); return result.rows[0] || null; } async function deleteKategorie(id: number) { await pool.query('DELETE FROM ausruestung_kategorien_katalog WHERE id = $1', [id]); } // --------------------------------------------------------------------------- // Catalog Items (ausruestung_artikel) // --------------------------------------------------------------------------- async function getItems(filters?: { kategorie?: string; kategorie_id?: number; aktiv?: boolean }) { const conditions: string[] = []; const params: unknown[] = []; if (filters?.kategorie) { params.push(filters.kategorie); conditions.push(`kategorie = $${params.length}`); } if (filters?.kategorie_id) { params.push(filters.kategorie_id); conditions.push(`kategorie_id = $${params.length}`); } if (filters?.aktiv !== undefined) { params.push(filters.aktiv); conditions.push(`aktiv = $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`, params, ); // 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 result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]); if (!result.rows[0]) return null; 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 row; } async function createItem( data: { bezeichnung: string; beschreibung?: string; kategorie?: string; kategorie_id?: number; geschaetzter_preis?: number; aktiv?: boolean; }, 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 (${cols.join(', ')}) VALUES (${placeholders}) RETURNING *`, vals, ); return result.rows[0]; } async function updateItem( id: number, data: { bezeichnung?: string; beschreibung?: string; kategorie?: string; kategorie_id?: number | null; geschaetzter_preis?: number; aktiv?: boolean; }, _userId: string, ) { const fields: string[] = []; const params: unknown[] = []; if (data.bezeichnung !== undefined) { params.push(data.bezeichnung); fields.push(`bezeichnung = $${params.length}`); } if (data.beschreibung !== undefined) { params.push(data.beschreibung); fields.push(`beschreibung = $${params.length}`); } if (data.kategorie !== undefined) { params.push(data.kategorie); fields.push(`kategorie = $${params.length}`); } if (data.kategorie_id !== undefined) { params.push(data.kategorie_id); fields.push(`kategorie_id = $${params.length}`); } if (data.geschaetzter_preis !== undefined) { params.push(data.geschaetzter_preis); fields.push(`geschaetzter_preis = $${params.length}`); } if (data.aktiv !== undefined) { params.push(data.aktiv); fields.push(`aktiv = $${params.length}`); } if (fields.length === 0) { return getItemById(id); } params.push(new Date()); fields.push(`aktualisiert_am = $${params.length}`); params.push(id); const result = await pool.query( `UPDATE ausruestung_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`, params, ); return result.rows[0] || null; } async function deleteItem(id: number) { await pool.query('DELETE FROM ausruestung_artikel WHERE id = $1', [id]); } async function getCategories() { const result = await pool.query( 'SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie', ); return result.rows.map((r: { kategorie: string }) => r.kategorie); } // --------------------------------------------------------------------------- // Artikel Eigenschaften (characteristics) // --------------------------------------------------------------------------- async function getArtikelEigenschaften(artikelId: number) { 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 (data.id) { const result = await pool.query( `UPDATE ausruestung_artikel_eigenschaften SET name = $1, typ = $2, optionen = $3, pflicht = $4, sort_order = $5 WHERE id = $6 AND artikel_id = $7 RETURNING *`, [data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0, data.id, artikelId], ); return result.rows[0] || null; } const result = await pool.query( `INSERT INTO ausruestung_artikel_eigenschaften (artikel_id, name, typ, optionen, pflicht, sort_order) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [artikelId, data.name, data.typ, data.optionen || null, data.pflicht ?? false, data.sort_order ?? 0], ); return result.rows[0]; } async function deleteArtikelEigenschaft(id: number) { await pool.query('DELETE FROM ausruestung_artikel_eigenschaften WHERE id = $1', [id]); } // --------------------------------------------------------------------------- // Requests (ausruestung_anfragen) // --------------------------------------------------------------------------- async function getRequests(filters?: { status?: string; anfrager_id?: string }) { const conditions: string[] = []; const params: unknown[] = []; if (filters?.status) { params.push(filters.status); conditions.push(`a.status = $${params.length}`); } if (filters?.anfrager_id) { params.push(filters.anfrager_id); conditions.push(`a.anfrager_id = $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT a.*, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von ${where} ORDER BY a.erstellt_am DESC`, params, ); return result.rows; } async function getMyRequests(userId: string) { const result = await pool.query( `SELECT a.*, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count FROM ausruestung_anfragen a WHERE a.anfrager_id = $1 ORDER BY a.erstellt_am DESC`, [userId], ); return result.rows; } async function getRequestById(id: number) { const reqResult = await pool.query( `SELECT a.*, COALESCE(u.given_name || ' ' || u.family_name, u.name) AS anfrager_name, COALESCE(u2.given_name || ' ' || u2.family_name, u2.name) AS bearbeitet_von_name FROM ausruestung_anfragen a LEFT JOIN users u ON u.id = a.anfrager_id LEFT JOIN users u2 ON u2.id = a.bearbeitet_von WHERE a.id = $1`, [id], ); if (reqResult.rows.length === 0) return null; const positionen = await pool.query( `SELECT p.*, sa.bezeichnung AS artikel_bezeichnung, sa.kategorie AS artikel_kategorie FROM ausruestung_anfrage_positionen p LEFT JOIN ausruestung_artikel sa ON sa.id = p.artikel_id WHERE p.anfrage_id = $1 ORDER BY p.id`, [id], ); // Load eigenschaft values per position const positionIds = positionen.rows.map((p: { id: number }) => p.id); let eigenschaftenMap: Record = {}; if (positionIds.length > 0) { 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 { /* table may not exist */ } } const positionenWithEigenschaften = positionen.rows.map((p: { id: number }) => ({ ...p, eigenschaften: eigenschaftenMap[p.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: linkedBestellungen, }; } async function createRequest( userId: string, items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], notizen?: string, bezeichnung?: string, ) { const client = await pool.connect(); try { await client.query('BEGIN'); const currentYear = new Date().getFullYear(); // Try with bestell_nummer/bestell_jahr (migration 050), fallback without let anfrage: Record; try { await client.query('SAVEPOINT sp_bestell_nr'); const maxResult = await client.query( `SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr FROM ausruestung_anfragen WHERE bestell_jahr = $1`, [currentYear], ); const nextNr = maxResult.rows[0].next_nr; const anfrageResult = await client.query( `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung, bestell_nummer, bestell_jahr) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [userId, notizen || null, bezeichnung || null, nextNr, currentYear], ); await client.query('RELEASE SAVEPOINT sp_bestell_nr'); anfrage = anfrageResult.rows[0]; } catch { await client.query('ROLLBACK TO SAVEPOINT sp_bestell_nr'); const anfrageResult = await client.query( `INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bezeichnung) VALUES ($1, $2, $3) RETURNING *`, [userId, notizen || null, bezeichnung || null], ); anfrage = anfrageResult.rows[0]; } for (const item of items) { let itemBezeichnung = item.bezeichnung; if (item.artikel_id) { const artikelResult = await client.query( 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', [item.artikel_id], ); if (artikelResult.rows.length > 0) { itemBezeichnung = artikelResult.rows[0].bezeichnung; } } await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen) VALUES ($1, $2, $3, $4, $5)`, [anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null], ); // 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 as number); } catch (error) { await client.query('ROLLBACK'); logger.error('ausruestungsanfrageService.createRequest failed', { error }); throw error; } finally { client.release(); } } async function updateRequest( id: number, data: { bezeichnung?: string; notizen?: string; items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[]; }, ) { const client = await pool.connect(); try { await client.query('BEGIN'); const fields: string[] = []; const params: unknown[] = []; if (data.bezeichnung !== undefined) { params.push(data.bezeichnung || null); fields.push(`bezeichnung = $${params.length}`); } if (data.notizen !== undefined) { params.push(data.notizen || null); fields.push(`notizen = $${params.length}`); } if (fields.length > 0) { params.push(new Date()); fields.push(`aktualisiert_am = $${params.length}`); params.push(id); await client.query( `UPDATE ausruestung_anfragen SET ${fields.join(', ')} WHERE id = $${params.length}`, params, ); } if (data.items) { // Preserve geliefert state for matching items (by artikel_id + bezeichnung) const existingPositionen = await client.query( 'SELECT artikel_id, bezeichnung, geliefert FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id], ); const geliefertMap = new Map(); for (const p of existingPositionen.rows) { geliefertMap.set(`${p.artikel_id ?? 0}:${p.bezeichnung}`, p.geliefert); } await client.query('DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1', [id]); for (const item of data.items) { let itemBezeichnung = item.bezeichnung; if (item.artikel_id) { const artikelResult = await client.query( 'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1', [item.artikel_id], ); if (artikelResult.rows.length > 0) { itemBezeichnung = artikelResult.rows[0].bezeichnung; } } const prevGeliefert = geliefertMap.get(`${item.artikel_id ?? 0}:${itemBezeichnung}`) ?? false; await client.query( `INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen, geliefert) VALUES ($1, $2, $3, $4, $5, $6)`, [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert], ); } } 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'); logger.error('ausruestungsanfrageService.updateRequest failed', { error }); throw error; } finally { client.release(); } } async function updatePositionGeliefert(positionId: number, geliefert: boolean) { const result = await pool.query( `UPDATE ausruestung_anfrage_positionen SET geliefert = $1 WHERE id = $2 RETURNING *`, [geliefert, positionId], ); const position = result.rows[0]; if (!position) return null; // Auto-complete: if all positions are geliefert, set anfrage status to 'erledigt' if (geliefert) { const check = await pool.query( `SELECT COUNT(*) FILTER (WHERE NOT geliefert)::int AS remaining FROM ausruestung_anfrage_positionen WHERE anfrage_id = $1`, [position.anfrage_id], ); if (check.rows[0].remaining === 0) { await pool.query( `UPDATE ausruestung_anfragen SET status = 'erledigt', aktualisiert_am = NOW() WHERE id = $1`, [position.anfrage_id], ); } } return position; } async function updateRequestStatus( id: number, status: string, adminNotizen?: string, bearbeitetVon?: string, ) { // Use aktualisiert_am (always exists) + try bearbeitet_am (added in migration 050) let updated; try { const result = await pool.query( `UPDATE ausruestung_anfragen SET status = $1, admin_notizen = COALESCE($2, admin_notizen), bearbeitet_von = COALESCE($3, bearbeitet_von), bearbeitet_am = NOW(), aktualisiert_am = NOW() WHERE id = $4 RETURNING *`, [status, adminNotizen || null, bearbeitetVon || null, id], ); updated = result.rows[0] || null; } catch { // Fallback if bearbeitet_am column doesn't exist yet (migration 050 not run) const result = await pool.query( `UPDATE ausruestung_anfragen SET status = $1, admin_notizen = COALESCE($2, admin_notizen), bearbeitet_von = COALESCE($3, bearbeitet_von), aktualisiert_am = NOW() WHERE id = $4 RETURNING *`, [status, adminNotizen || null, bearbeitetVon || null, id], ); updated = result.rows[0] || null; } // When status changes to 'erledigt', mark all positions as geliefert if (status === 'erledigt') { try { await pool.query( `UPDATE ausruestung_anfrage_positionen SET geliefert = true WHERE anfrage_id = $1`, [id], ); } catch { /* column may not exist yet */ } } return updated; } async function deleteRequest(id: number) { await pool.query('DELETE FROM ausruestung_anfragen WHERE id = $1', [id]); } // --------------------------------------------------------------------------- // Linking (ausruestung_anfrage_bestellung) // --------------------------------------------------------------------------- async function linkToOrder(anfrageId: number, bestellungId: number) { await pool.query( `INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [anfrageId, bestellungId], ); } async function unlinkFromOrder(anfrageId: number, bestellungId: number) { await pool.query( 'DELETE FROM ausruestung_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2', [anfrageId, bestellungId], ); } async function getLinkedOrders(anfrageId: number) { const result = await pool.query( `SELECT b.* FROM ausruestung_anfrage_bestellung ab JOIN bestellungen b ON b.id = ab.bestellung_id WHERE ab.anfrage_id = $1`, [anfrageId], ); return result.rows; } // --------------------------------------------------------------------------- // Overview (aggregated) // --------------------------------------------------------------------------- async function getOverview() { const aggregated = await pool.query( `SELECT p.bezeichnung, SUM(p.menge)::int AS total_menge, COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count FROM ausruestung_anfrage_positionen p JOIN ausruestung_anfragen a ON a.id = p.anfrage_id WHERE a.status IN ('offen', 'genehmigt') GROUP BY p.bezeichnung ORDER BY total_menge DESC, p.bezeichnung`, ); const counts = await pool.query( `SELECT 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, COALESCE(SUM(sub.total), 0)::int AS total_items FROM ausruestung_anfragen a LEFT JOIN LATERAL ( SELECT SUM(p.menge) AS total FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id ) sub ON true WHERE a.status IN ('offen', 'genehmigt')`, ); return { items: aggregated.rows, ...counts.rows[0], }; } // --------------------------------------------------------------------------- // Widget overview (lightweight 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, updateKategorie, deleteKategorie, getItems, getItemById, createItem, updateItem, deleteItem, getCategories, getArtikelEigenschaften, upsertArtikelEigenschaft, deleteArtikelEigenschaft, getRequests, getMyRequests, getRequestById, updatePositionGeliefert, createRequest, updateRequest, updateRequestStatus, deleteRequest, linkToOrder, unlinkFromOrder, getLinkedOrders, getOverview, getWidgetOverview, };