import pool from '../config/database'; import logger from '../utils/logger'; import { Fahrzeug, FahrzeugListItem, FahrzeugDetail, FahrzeugWartungslog, CreateFahrzeugData, UpdateFahrzeugData, CreateWartungslogData, UpdateWartungslogData, FahrzeugStatus, VehicleStats, InspectionAlert, OverlappingBooking, } from '../models/vehicle.model'; class VehicleService { // ========================================================================= // FLEET OVERVIEW // ========================================================================= async getAllVehicles(): Promise { try { const result = await pool.query(` SELECT id, bezeichnung, kurzname, amtliches_kennzeichen, baujahr, hersteller, besatzung_soll, status, status_bemerkung, ausser_dienst_von, ausser_dienst_bis, bild_url, paragraph57a_faellig_am, paragraph57a_tage_bis_faelligkeit, naechste_wartung_am, wartung_tage_bis_faelligkeit, naechste_pruefung_tage FROM fahrzeuge_mit_pruefstatus ORDER BY bezeichnung ASC `); // Fetch active lehrgang bookings for all vehicles in one query const lehrgangs = await pool.query(` SELECT DISTINCT ON (b.fahrzeug_id) b.fahrzeug_id, b.titel, b.beginn, b.ende FROM fahrzeug_buchungen b WHERE b.buchungs_art = 'lehrgang' AND b.abgesagt = FALSE AND b.beginn <= NOW() AND b.ende >= NOW() ORDER BY b.fahrzeug_id, b.beginn ASC `); const lehrgangsMap = new Map(); for (const r of lehrgangs.rows) { lehrgangsMap.set(r.fahrzeug_id, { titel: r.titel, beginn: new Date(r.beginn), ende: new Date(r.ende) }); } return result.rows.map((row) => ({ ...row, paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, aktiver_lehrgang: lehrgangsMap.get(row.id) ?? null, })) as FahrzeugListItem[]; } catch (error) { logger.error('VehicleService.getAllVehicles failed', { error }); throw new Error('Failed to fetch vehicles'); } } // ========================================================================= // VEHICLE DETAIL // ========================================================================= async getVehicleById(id: string): Promise { try { const vehicleResult = await pool.query( `SELECT * FROM fahrzeuge_mit_pruefstatus WHERE id = $1`, [id] ); if (vehicleResult.rows.length === 0) return null; const row = vehicleResult.rows[0]; const wartungslogResult = await pool.query( `SELECT * FROM fahrzeug_wartungslog WHERE fahrzeug_id = $1 ORDER BY datum DESC, created_at DESC`, [id] ); const vehicle: FahrzeugDetail = { id: row.id, bezeichnung: row.bezeichnung, kurzname: row.kurzname, amtliches_kennzeichen: row.amtliches_kennzeichen, fahrgestellnummer: row.fahrgestellnummer, baujahr: row.baujahr, hersteller: row.hersteller, typ_schluessel: row.typ_schluessel, besatzung_soll: row.besatzung_soll, status: row.status as FahrzeugStatus, status_bemerkung: row.status_bemerkung, ausser_dienst_von: row.ausser_dienst_von ?? null, ausser_dienst_bis: row.ausser_dienst_bis ?? null, standort: row.standort, bild_url: row.bild_url, paragraph57a_faellig_am: row.paragraph57a_faellig_am ?? null, naechste_wartung_am: row.naechste_wartung_am ?? null, created_at: row.created_at, updated_at: row.updated_at, paragraph57a_tage_bis_faelligkeit: row.paragraph57a_tage_bis_faelligkeit != null ? parseInt(row.paragraph57a_tage_bis_faelligkeit, 10) : null, wartung_tage_bis_faelligkeit: row.wartung_tage_bis_faelligkeit != null ? parseInt(row.wartung_tage_bis_faelligkeit, 10) : null, naechste_pruefung_tage: row.naechste_pruefung_tage != null ? parseInt(row.naechste_pruefung_tage, 10) : null, wartungslog: wartungslogResult.rows.map(r => ({ ...r, kosten: r.kosten != null ? Number(r.kosten) : null, })) as FahrzeugWartungslog[], }; // Fetch active lehrgang booking if any const lehrgang = await pool.query(` SELECT titel, beginn, ende FROM fahrzeug_buchungen WHERE fahrzeug_id = $1 AND buchungs_art = 'lehrgang' AND abgesagt = FALSE AND beginn <= NOW() AND ende >= NOW() ORDER BY beginn ASC LIMIT 1 `, [id]); vehicle.aktiver_lehrgang = lehrgang.rows.length > 0 ? { titel: lehrgang.rows[0].titel, beginn: lehrgang.rows[0].beginn, ende: lehrgang.rows[0].ende } : null; return vehicle; } catch (error) { logger.error('VehicleService.getVehicleById failed', { error, id }); throw new Error('Failed to fetch vehicle'); } } // ========================================================================= // CRUD // ========================================================================= async createVehicle(data: CreateFahrzeugData, createdBy: string): Promise { try { const result = await pool.query( `INSERT INTO fahrzeuge ( bezeichnung, kurzname, amtliches_kennzeichen, fahrgestellnummer, baujahr, hersteller, typ_schluessel, besatzung_soll, status, status_bemerkung, standort, bild_url, paragraph57a_faellig_am, naechste_wartung_am ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`, [ data.bezeichnung, data.kurzname ?? null, data.amtliches_kennzeichen ?? null, data.fahrgestellnummer ?? null, data.baujahr ?? null, data.hersteller ?? null, data.typ_schluessel ?? null, data.besatzung_soll ?? null, data.status ?? FahrzeugStatus.Einsatzbereit, data.status_bemerkung ?? null, data.standort ?? 'Feuerwehrhaus', data.bild_url ?? null, data.paragraph57a_faellig_am ?? null, data.naechste_wartung_am ?? null, ] ); const vehicle = result.rows[0] as Fahrzeug; logger.info('Vehicle created', { id: vehicle.id, by: createdBy }); return vehicle; } catch (error) { logger.error('VehicleService.createVehicle failed', { error, createdBy }); throw new Error('Failed to create vehicle'); } } async updateVehicle(id: string, data: UpdateFahrzeugData, updatedBy: string): Promise { try { const fields: string[] = []; const values: unknown[] = []; let p = 1; const addField = (col: string, value: unknown) => { fields.push(`${col} = $${p++}`); values.push(value); }; if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung); if (data.kurzname !== undefined) addField('kurzname', data.kurzname); if (data.amtliches_kennzeichen !== undefined) addField('amtliches_kennzeichen', data.amtliches_kennzeichen); if (data.fahrgestellnummer !== undefined) addField('fahrgestellnummer', data.fahrgestellnummer); if (data.baujahr !== undefined) addField('baujahr', data.baujahr); if (data.hersteller !== undefined) addField('hersteller', data.hersteller); if (data.typ_schluessel !== undefined) addField('typ_schluessel', data.typ_schluessel); if (data.besatzung_soll !== undefined) addField('besatzung_soll', data.besatzung_soll); if (data.status !== undefined) addField('status', data.status); if (data.status_bemerkung !== undefined) addField('status_bemerkung', data.status_bemerkung); if (data.standort !== undefined) addField('standort', data.standort); if (data.bild_url !== undefined) addField('bild_url', data.bild_url); if (data.paragraph57a_faellig_am !== undefined) addField('paragraph57a_faellig_am', data.paragraph57a_faellig_am); if (data.naechste_wartung_am !== undefined) addField('naechste_wartung_am', data.naechste_wartung_am); if (fields.length === 0) { throw new Error('No fields to update'); } values.push(id); const result = await pool.query( `UPDATE fahrzeuge SET ${fields.join(', ')} WHERE id = $${p} AND deleted_at IS NULL RETURNING *`, values ); if (result.rows.length === 0) { throw new Error('Vehicle not found'); } const vehicle = result.rows[0] as Fahrzeug; logger.info('Vehicle updated', { id, by: updatedBy }); return vehicle; } catch (error) { logger.error('VehicleService.updateVehicle failed', { error, id, updatedBy }); throw error; } } async deleteVehicle(id: string, deletedBy: string): Promise { try { const result = await pool.query( `UPDATE fahrzeuge SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL RETURNING id`, [id] ); if (result.rows.length === 0) { throw new Error('Vehicle not found'); } logger.info('Vehicle soft-deleted', { id, by: deletedBy }); } catch (error) { logger.error('VehicleService.deleteVehicle failed', { error, id }); throw error; } } // ========================================================================= // STATUS MANAGEMENT // ========================================================================= async updateVehicleStatus( id: string, status: FahrzeugStatus, bemerkung: string, updatedBy: string, io?: any, ausserDienstVon?: Date | null, ausserDienstBis?: Date | null, ): Promise<{ overlappingBookings: OverlappingBooking[] }> { const client = await pool.connect(); try { await client.query('BEGIN'); const oldResult = await client.query( `SELECT bezeichnung, status FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, [id] ); if (oldResult.rows.length === 0) { await client.query('ROLLBACK'); throw new Error('Vehicle not found'); } const { bezeichnung, status: oldStatus } = oldResult.rows[0]; const isAusserDienst = status === FahrzeugStatus.AusserDienstWartung || status === FahrzeugStatus.AusserDienstSchaden; await client.query( `UPDATE fahrzeuge SET status = $1, status_bemerkung = $2, ausser_dienst_von = $3, ausser_dienst_bis = $4 WHERE id = $5`, [ status, bemerkung || null, isAusserDienst ? (ausserDienstVon ?? null) : null, isAusserDienst ? (ausserDienstBis ?? null) : null, id, ] ); // Record status change history if (oldStatus !== status) { await client.query( `INSERT INTO fahrzeug_status_historie (fahrzeug_id, alter_status, neuer_status, bemerkung, geaendert_von) VALUES ($1, $2, $3, $4, $5)`, [id, oldStatus, status, bemerkung || null, updatedBy] ); } await client.query('COMMIT'); logger.info('Vehicle status updated', { id, from: oldStatus, to: status, by: updatedBy }); // Find bookings that overlap with the out-of-service period let overlappingBookings: OverlappingBooking[] = []; if (isAusserDienst && ausserDienstVon && ausserDienstBis) { const overlap = await pool.query( `SELECT b.id, b.titel, b.beginn, b.ende, u.name AS gebucht_von_name FROM fahrzeug_buchungen b JOIN users u ON u.id = b.gebucht_von WHERE b.fahrzeug_id = $1 AND b.abgesagt = FALSE AND ($2::timestamptz, $3::timestamptz) OVERLAPS (b.beginn, b.ende)`, [id, ausserDienstVon, ausserDienstBis] ); overlappingBookings = overlap.rows; } if (io) { io.emit('vehicle:statusChanged', { vehicleId: id, bezeichnung, oldStatus, newStatus: status, bemerkung: bemerkung || null, ausserDienstVon: isAusserDienst ? ausserDienstVon ?? null : null, ausserDienstBis: isAusserDienst ? ausserDienstBis ?? null : null, updatedBy, timestamp: new Date().toISOString(), }); } return { overlappingBookings }; } catch (error) { await client.query('ROLLBACK').catch(() => {}); logger.error('VehicleService.updateVehicleStatus failed', { error, id }); throw error; } finally { client.release(); } } // ========================================================================= // MAINTENANCE LOG // ========================================================================= async addWartungslog( fahrzeugId: string, data: CreateWartungslogData, createdBy: string ): Promise { try { const check = await pool.query( `SELECT 1 FROM fahrzeuge WHERE id = $1 AND deleted_at IS NULL`, [fahrzeugId] ); if (check.rows.length === 0) { throw new Error('Vehicle not found'); } const result = await pool.query( `INSERT INTO fahrzeug_wartungslog ( fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten, externe_werkstatt, ergebnis, naechste_faelligkeit, erfasst_von ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [ fahrzeugId, data.datum, data.art ?? null, data.beschreibung, data.km_stand ?? null, data.kraftstoff_liter ?? null, data.kosten ?? null, data.externe_werkstatt ?? null, data.ergebnis ?? null, data.naechste_faelligkeit ?? null, createdBy, ] ); const entry = result.rows[0] as FahrzeugWartungslog; // Auto-update next service date on the vehicle when result is 'bestanden' if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am'; await pool.query( `UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`, [data.naechste_faelligkeit, fahrzeugId] ); } logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy }); return entry; } catch (error) { logger.error('VehicleService.addWartungslog failed', { error, fahrzeugId }); throw error; } } async updateWartungslog( wartungId: string, fahrzeugId: string, data: UpdateWartungslogData, updatedBy: string ): Promise { try { const result = await pool.query( `UPDATE fahrzeug_wartungslog SET datum = $1, art = $2, beschreibung = $3, km_stand = $4, externe_werkstatt = $5, ergebnis = $6, naechste_faelligkeit = $7 WHERE id = $8 AND fahrzeug_id = $9 RETURNING *`, [ data.datum, data.art ?? null, data.beschreibung, data.km_stand ?? null, data.externe_werkstatt ?? null, data.ergebnis ?? null, data.naechste_faelligkeit ?? null, wartungId, fahrzeugId, ] ); if (result.rows.length === 0) { throw new Error('Wartungseintrag nicht gefunden'); } const entry = result.rows[0] as FahrzeugWartungslog; // Auto-update next service date on the vehicle when result is 'bestanden' if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) { const column = data.art === '§57a Prüfung' ? 'paragraph57a_faellig_am' : 'naechste_wartung_am'; await pool.query( `UPDATE fahrzeuge SET ${column} = $1 WHERE id = $2`, [data.naechste_faelligkeit, fahrzeugId] ); } logger.info('Wartungslog entry updated', { wartungId, fahrzeugId, by: updatedBy }); return entry; } catch (error) { logger.error('VehicleService.updateWartungslog failed', { error, wartungId, fahrzeugId }); throw error; } } async getWartungslogForVehicle(fahrzeugId: string): Promise { try { const result = await pool.query( `SELECT * FROM fahrzeug_wartungslog WHERE fahrzeug_id = $1 ORDER BY datum DESC, created_at DESC`, [fahrzeugId] ); return result.rows.map(r => ({ ...r, kosten: r.kosten != null ? Number(r.kosten) : null, })) as FahrzeugWartungslog[]; } catch (error) { logger.error('VehicleService.getWartungslogForVehicle failed', { error, fahrzeugId }); throw new Error('Failed to fetch maintenance log'); } } // ========================================================================= // MAINTENANCE WINDOWS (for booking calendar overlay) // ========================================================================= async getMaintenanceWindows( from: Date, to: Date ): Promise< { id: string; bezeichnung: string; kurzname: string | null; status: string; status_bemerkung: string | null; ausser_dienst_von: string; ausser_dienst_bis: string; }[] > { try { const result = await pool.query( `SELECT id, bezeichnung, kurzname, status, status_bemerkung, ausser_dienst_von, ausser_dienst_bis FROM fahrzeuge WHERE deleted_at IS NULL AND status IN ('ausser_dienst_wartung', 'ausser_dienst_schaden') AND ausser_dienst_von IS NOT NULL AND ausser_dienst_bis IS NOT NULL AND (ausser_dienst_von, ausser_dienst_bis) OVERLAPS ($1, $2)`, [from, to] ); return result.rows; } catch (error) { logger.error('VehicleService.getMaintenanceWindows failed', { error }); throw new Error('Failed to fetch maintenance windows'); } } // ========================================================================= // DASHBOARD KPI // ========================================================================= async getVehicleStats(): Promise { try { const totalsResult = await pool.query(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'einsatzbereit') AS einsatzbereit, COUNT(*) FILTER ( WHERE status IN ('ausser_dienst_wartung','ausser_dienst_schaden') ) AS ausser_dienst FROM fahrzeuge WHERE deleted_at IS NULL `); // Count vehicles with an active lehrgang booking const lehrgangsResult = await pool.query(` SELECT COUNT(DISTINCT b.fahrzeug_id) AS in_lehrgang FROM fahrzeug_buchungen b WHERE b.buchungs_art = 'lehrgang' AND b.abgesagt = FALSE AND b.beginn <= NOW() AND b.ende >= NOW() `); const alertResult = await pool.query(` SELECT COUNT(*) FILTER ( WHERE ( (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE BETWEEN 0 AND 30) OR (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE BETWEEN 0 AND 30) ) ) AS inspections_due, COUNT(*) FILTER ( WHERE ( (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date < CURRENT_DATE) OR (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date < CURRENT_DATE) ) ) AS inspections_overdue FROM fahrzeuge WHERE deleted_at IS NULL `); const totals = totalsResult.rows[0] ?? { total: '0', einsatzbereit: '0', ausser_dienst: '0' }; const lehrgangs = lehrgangsResult.rows[0] ?? { in_lehrgang: '0' }; const alerts = alertResult.rows[0] ?? { inspections_due: '0', inspections_overdue: '0' }; return { total: parseInt(totals.total ?? '0', 10), einsatzbereit: parseInt(totals.einsatzbereit ?? '0', 10), ausserDienst: parseInt(totals.ausser_dienst ?? '0', 10), inLehrgang: parseInt(lehrgangs.in_lehrgang ?? '0', 10), inspectionsDue: parseInt(alerts.inspections_due ?? '0', 10), inspectionsOverdue: parseInt(alerts.inspections_overdue ?? '0', 10), }; } catch (error) { logger.error('VehicleService.getVehicleStats failed', { error }); throw new Error('Failed to fetch vehicle stats'); } } async getUpcomingInspections(daysAhead: number): Promise { try { const result = await pool.query( `SELECT id AS fahrzeug_id, bezeichnung, kurzname, paragraph57a_faellig_am, paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage, naechste_wartung_am, naechste_wartung_am::date - CURRENT_DATE AS wartung_tage FROM fahrzeuge WHERE deleted_at IS NULL AND ( (paragraph57a_faellig_am IS NOT NULL AND paragraph57a_faellig_am::date - CURRENT_DATE <= $1) OR (naechste_wartung_am IS NOT NULL AND naechste_wartung_am::date - CURRENT_DATE <= $1) ) ORDER BY LEAST( CASE WHEN paragraph57a_faellig_am IS NOT NULL THEN paragraph57a_faellig_am::date - CURRENT_DATE END, CASE WHEN naechste_wartung_am IS NOT NULL THEN naechste_wartung_am::date - CURRENT_DATE END ) ASC NULLS LAST`, [daysAhead] ); const alerts: InspectionAlert[] = []; for (const row of result.rows) { if (row.paragraph57a_faellig_am !== null && row.paragraph57a_tage !== null) { const tage = parseInt(row.paragraph57a_tage, 10); if (tage <= daysAhead) { alerts.push({ fahrzeugId: row.fahrzeug_id, bezeichnung: row.bezeichnung, kurzname: row.kurzname, type: '57a', faelligAm: row.paragraph57a_faellig_am, tage, }); } } if (row.naechste_wartung_am !== null && row.wartung_tage !== null) { const tage = parseInt(row.wartung_tage, 10); if (tage <= daysAhead) { alerts.push({ fahrzeugId: row.fahrzeug_id, bezeichnung: row.bezeichnung, kurzname: row.kurzname, type: 'wartung', faelligAm: row.naechste_wartung_am, tage, }); } } } alerts.sort((a, b) => a.tage - b.tage); return alerts; } catch (error) { logger.error('VehicleService.getUpcomingInspections failed', { error, daysAhead }); throw new Error('Failed to fetch inspection alerts'); } } // ========================================================================= // STATUS HISTORY // ========================================================================= async getStatusHistory(fahrzeugId: string) { try { const result = await pool.query( `SELECT h.*, u.display_name AS geaendert_von_name FROM fahrzeug_status_historie h LEFT JOIN users u ON u.id = h.geaendert_von WHERE h.fahrzeug_id = $1 ORDER BY h.erstellt_am DESC LIMIT 50`, [fahrzeugId] ); return result.rows; } catch (error) { logger.error('VehicleService.getStatusHistory failed', { error, fahrzeugId }); throw new Error('Status-Historie konnte nicht geladen werden'); } } // ========================================================================= // WARTUNGSLOG FILE UPLOAD // ========================================================================= async updateWartungslogFile(wartungId: number, filePath: string) { try { const result = await pool.query( `UPDATE fahrzeug_wartungslog SET dokument_url = $1 WHERE id = $2 RETURNING *`, [filePath, wartungId] ); if (result.rows.length === 0) { throw new Error('Wartungseintrag nicht gefunden'); } return result.rows[0]; } catch (error) { logger.error('VehicleService.updateWartungslogFile failed', { error, wartungId }); throw error; } } } export default new VehicleService();