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; search?: string }) { const conditions: string[] = []; const params: unknown[] = []; if (filters?.kategorie) { params.push(filters.kategorie); conditions.push(`aa.kategorie = $${params.length}`); } if (filters?.kategorie_id) { params.push(filters.kategorie_id); conditions.push(`aa.kategorie_id = $${params.length}`); } if (filters?.aktiv !== undefined) { params.push(filters.aktiv); conditions.push(`aa.aktiv = $${params.length}`); } if (filters?.search) { params.push(`%${filters.search}%`); conditions.push(`aa.bezeichnung ILIKE $${params.length}`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT aa.*, aa.bevorzugter_lieferant_id, l.name AS bevorzugter_lieferant_name FROM ausruestung_artikel aa LEFT JOIN lieferanten l ON l.id = aa.bevorzugter_lieferant_id ${where} ORDER BY aa.kategorie, aa.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 aa.*, aa.bevorzugter_lieferant_id, l.name AS bevorzugter_lieferant_name FROM ausruestung_artikel aa LEFT JOIN lieferanten l ON l.id = aa.bevorzugter_lieferant_id WHERE aa.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; bevorzugter_lieferant_id?: number | null; }, userId: string, ) { // Build column list dynamically based on whether kategorie_id column exists const cols = ['bezeichnung', 'beschreibung', 'kategorie', 'geschaetzter_preis', 'aktiv', 'erstellt_von', 'bevorzugter_lieferant_id']; const vals = [data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId, data.bevorzugter_lieferant_id ?? null]; 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; bevorzugter_lieferant_id?: number | null; }, _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 (data.bevorzugter_lieferant_id !== undefined) { params.push(data.bevorzugter_lieferant_id); fields.push(`bevorzugter_lieferant_id = $${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]); } // --------------------------------------------------------------------------- // Users (for "order on behalf of" autocomplete) // --------------------------------------------------------------------------- async function getAllUsers() { const result = await pool.query( `SELECT id, COALESCE(given_name || ' ' || family_name, name) AS name FROM users WHERE is_active = true ORDER BY name`, ); return result.rows; } // --------------------------------------------------------------------------- // 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, a.fuer_benutzer_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, (SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_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, a.fuer_benutzer_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 */ } // Determine im_haus: true if any linked bestellung has status lieferung_pruefen or abgeschlossen const imHaus = (linkedBestellungen as { status: string }[]).some( (b) => b.status === 'lieferung_pruefen' || b.status === 'abgeschlossen', ); return { anfrage: reqResult.rows[0], positionen: positionenWithEigenschaften, linked_bestellungen: linkedBestellungen, im_haus: imHaus, }; } async function createRequest( userId: string, items: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; ist_ersatz?: boolean; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[], notizen?: string, bezeichnung?: string, fuerBenutzerName?: 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, fuer_benutzer_name) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [userId, notizen || null, bezeichnung || null, nextNr, currentYear, fuerBenutzerName || null], ); 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, fuer_benutzer_name) VALUES ($1, $2, $3, $4) RETURNING *`, [userId, notizen || null, bezeichnung || null, fuerBenutzerName || 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, ist_ersatz) VALUES ($1, $2, $3, $4, $5, $6)`, [anfrage.id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, item.ist_ersatz ?? false], ); // 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; ist_ersatz?: boolean; 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, ist_ersatz) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [id, item.artikel_id || null, itemBezeichnung, item.menge, item.notizen || null, prevGeliefert, item.ist_ersatz ?? false], ); } } 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 updatePositionZurueckgegeben(positionId: number, zurueckgegeben: boolean) { const result = await pool.query( `UPDATE ausruestung_anfrage_positionen SET altes_geraet_zurueckgegeben = $1 WHERE id = $2 RETURNING *`, [zurueckgegeben, positionId], ); return result.rows[0] || null; } 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; } async function createOrdersFromRequest( anfrageId: number, orders: Array<{ lieferant_id: number; bezeichnung: string; positionen: Array<{ position_id: number; bezeichnung: string; menge: number; einheit?: string; notizen?: string }>; }>, userId: string, ) { const client = await pool.connect(); try { await client.query('BEGIN'); const createdBestellungen: Array<{ id: number; bezeichnung: string; lieferant_name: string }> = []; for (const orderData of orders) { const nrResult = await client.query( `SELECT COALESCE(MAX(laufende_nummer), 0) + 1 AS next_nr FROM bestellungen WHERE EXTRACT(YEAR FROM erstellt_am) = EXTRACT(YEAR FROM NOW())` ); const laufendeNummer = nrResult.rows[0].next_nr; const bestellungResult = await client.query( `INSERT INTO bestellungen (bezeichnung, lieferant_id, status, laufende_nummer, erstellt_von) VALUES ($1, $2, 'wartet_auf_genehmigung', $3, $4) RETURNING id, bezeichnung`, [orderData.bezeichnung, orderData.lieferant_id, laufendeNummer, userId] ); const bestellung = bestellungResult.rows[0]; for (const pos of orderData.positionen) { // Look up the anfrage position to get artikel_id and eigenschaften let artikelId: number | null = null; let spezifikationen: string[] = []; if (pos.position_id) { const posResult = await client.query( `SELECT p.artikel_id FROM ausruestung_anfrage_positionen p WHERE p.id = $1`, [pos.position_id] ); if (posResult.rows.length > 0) { artikelId = posResult.rows[0].artikel_id || null; } // Load eigenschaften and map to spezifikationen strings try { const eigResult = await client.query( `SELECT 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 = $1 ORDER BY ae.sort_order, ae.id`, [pos.position_id] ); spezifikationen = eigResult.rows.map((e: { eigenschaft_name: string; wert: string }) => `${e.eigenschaft_name}: ${e.wert}`); } catch { /* table may not exist */ } } await client.query( `INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`, [bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)] ); } await client.query( `INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [anfrageId, bestellung.id] ); const vendorResult = await client.query('SELECT name FROM lieferanten WHERE id = $1', [orderData.lieferant_id]); const lieferantName = vendorResult.rows[0]?.name ?? ''; createdBestellungen.push({ id: bestellung.id, bezeichnung: bestellung.bezeichnung, lieferant_name: lieferantName }); } await client.query( `UPDATE ausruestung_anfragen SET status = 'bestellt', aktualisiert_am = NOW() WHERE id = $1`, [anfrageId] ); await client.query('COMMIT'); return createdBestellungen; } catch (error) { await client.query('ROLLBACK'); logger.error('AusruestungsanfrageService.createOrdersFromRequest failed', { error }); throw new Error('Bestellungen konnten nicht erstellt werden'); } finally { client.release(); } } // --------------------------------------------------------------------------- // 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]; } // --------------------------------------------------------------------------- // Assignment of delivered items // --------------------------------------------------------------------------- interface AssignmentInput { positionId: number; typ: 'ausruestung' | 'persoenlich' | 'keine'; fahrzeugId?: string; standort?: string; userId?: string; benutzerName?: string; groesse?: string; kategorie?: string; } async function assignDeliveredItems( anfrageId: number, requestingUserId: string, assignments: AssignmentInput[], ) { const client = await pool.connect(); try { await client.query('BEGIN'); // Load anfrage to get anfrager_id (default user for personal assignments) const anfrageResult = await client.query( 'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1', [anfrageId], ); if (anfrageResult.rows.length === 0) { throw new Error('Anfrage nicht gefunden'); } const anfragerId = anfrageResult.rows[0].anfrager_id; let assigned = 0; for (const a of assignments) { // Load position details const posResult = await client.query( 'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2', [a.positionId, anfrageId], ); if (posResult.rows.length === 0) continue; const pos = posResult.rows[0]; if (a.typ === 'ausruestung') { // Look up kategorie_id from artikel if available let kategorieId: string | null = null; if (pos.artikel_id) { const artikelResult = await client.query( 'SELECT kategorie_id FROM ausruestung_artikel WHERE id = $1', [pos.artikel_id], ); if (artikelResult.rows[0]?.kategorie_id) { // artikel has kategorie_id (int FK to ausruestung_kategorien_katalog), but // ausruestung.kategorie_id is UUID FK to ausruestung_kategorien — look up by name const katNameResult = await client.query( 'SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1', [artikelResult.rows[0].kategorie_id], ); if (katNameResult.rows[0]?.name) { const katResult = await client.query( 'SELECT id FROM ausruestung_kategorien WHERE name = $1 LIMIT 1', [katNameResult.rows[0].name], ); kategorieId = katResult.rows[0]?.id ?? null; } } } // Fallback: pick first category if (!kategorieId) { const fallback = await client.query('SELECT id FROM ausruestung_kategorien LIMIT 1'); kategorieId = fallback.rows[0]?.id ?? null; } const insertResult = await client.query( `INSERT INTO ausruestung ( id, bezeichnung, kategorie_id, fahrzeug_id, standort, status, ist_wichtig ) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'einsatzbereit', false) RETURNING id`, [pos.bezeichnung, kategorieId, a.fahrzeugId ?? null, a.standort ?? 'Lager'], ); const newId = insertResult.rows[0].id; await client.query( `UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'ausruestung', zuweisung_ausruestung_id = $1 WHERE id = $2`, [newId, a.positionId], ); } else if (a.typ === 'persoenlich') { const insertResult = await client.query( `INSERT INTO persoenliche_ausruestung ( bezeichnung, kategorie, groesse, user_id, benutzer_name, anfrage_id, anfrage_position_id, artikel_id, erstellt_von ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, [ pos.bezeichnung, a.kategorie ?? null, a.groesse ?? null, a.userId ?? anfragerId, a.benutzerName ?? null, anfrageId, a.positionId, pos.artikel_id ?? null, requestingUserId, ], ); const newId = insertResult.rows[0].id; await client.query( `UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1 WHERE id = $2`, [newId, a.positionId], ); } else { // typ === 'keine' await client.query( `UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'keine' WHERE id = $1`, [a.positionId], ); } assigned++; } await client.query('COMMIT'); return { assigned }; } catch (error) { await client.query('ROLLBACK'); logger.error('ausruestungsanfrageService.assignDeliveredItems failed', { error, anfrageId }); throw error; } finally { client.release(); } } export default { getAllUsers, getKategorien, createKategorie, updateKategorie, deleteKategorie, getItems, getItemById, createItem, updateItem, deleteItem, getCategories, getArtikelEigenschaften, upsertArtikelEigenschaft, deleteArtikelEigenschaft, getRequests, getMyRequests, getRequestById, updatePositionGeliefert, updatePositionZurueckgegeben, createRequest, updateRequest, updateRequestStatus, deleteRequest, linkToOrder, unlinkFromOrder, getLinkedOrders, createOrdersFromRequest, getOverview, getWidgetOverview, assignDeliveredItems, };